fix(self-healing): 18 recovery rebounds hardcoded todo and THREW on a renamed board (#3150, first slice) (#3152)
First slice of #3150. `self-healing.ts` held **26** `moveTask` calls with a legacy literal target; this converts the **18 `todo` rebounds**. ## Why this is worse than a guard, and documented already `task-store/moves.ts` records it from a previous incident: > `moveTaskInternal` **REJECTS** a target the workflow does not declare (`TransitionRejectionError: unknown-column`) … completion handoff did not silently no-op — it **THREW**. Every one of these 18 is a **recovery**. On a renamed board they threw instead of rebounding, so the strand each sweep exists to clear survived *and* the sweep reported failure. The reliability layer meant to be the backstop was the layer that broke. ## Why the census never saw it It counts **comparisons** against legacy ids. A move target is an **argument**. That is the third blind spot of the same instrument, and all three have now produced real defects found by hand: | blind spot | found this session | |---|---| | definitions | `GITHUB_TRACKING_EDITABLE_COLUMNS` — tracking unreachable on renamed boards (#3149) | | collections | swept: 30 sites, 29 already correct, 1 defect (the above) | | **targets** | **this** — 26 in one file, 31 tree-wide | ## Why 18 sites at once is safe `resolveReboundTargetForTask` **degrades to `"todo"`** when no workflow resolves, and `self-healing.ts` already used it at line 745. On every board we ship, the resolved answer *is* `todo` — so default behaviour is unchanged **by construction**, not by inspection. The control case pins exactly that, and it is the reason this can land as one change rather than eighteen. ## Scope, and what I deliberately did not touch Converted: the 18 `todo` rebounds. **Not** converted: the `done`, `archived` and `in-review` targets. They need different helpers and genuine reasoning about which lane a completion or an archive belongs in — converting them by analogy is exactly the half-conversion this program keeps paying for. Sites with no resolver in scope are unchanged. The audit behind the split is in the commit: of 26 sites, 5 had resolved lanes in scope, 4 had an IR, 17 had nothing — and `lanesOfReclaim` returns **Sets**, which is the wrong arity for a target (a move takes exactly one column, per the `moves.ts` note). ## Verification | | result | |---|---| | engine `tsc` | **0 errors** | | **all 43 self-healing suites** | **843 passed** | | census `--strict` | exit 0, **unchanged** — invisible to it | | `check-inert-sync-lanes` | exit 0 | | differential | restoring the literal → **1 failed \| 1 passed**, renamed case only | The new test drives a **public entry point** (`reconcileInReviewUnmetDependencies`, the FN-6793 contract) rather than calling the helper directly, so it covers the producer path too. One harness note worth keeping: the first version of the test failed **upstream** of the target, because the sweep selects rows via `resolveProjectColumnsForRoles` — a *project-level* resolver reading `listWorkflowDefinitions`, not the task's own selection. Without that mocked, the renamed card was never considered and the failure looked like the fix not working. That distinction (project-level vocabulary vs per-task IR) will bite the next slices too. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Tasks now move to workflow-specific rebound, completion, and archive columns instead of fixed default destinations. * Retrying and recovering tasks works correctly on boards with renamed lifecycle columns. * Added safe fallback behavior for workflows without custom lifecycle settings. * **Tests** * Added coverage to prevent legacy hardcoded task destinations and verify renamed-column recovery scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2451,16 +2451,25 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
...buildManualRetryResetPatch({ resetMergeRetries: true }),
|
||||
});
|
||||
|
||||
// Move to todo column
|
||||
/*
|
||||
FNXC:TaskRetry 2026-07-31-23:59 (the SECOND instance of the review finding on #3152):
|
||||
A reviewer caught the chat `fn_task_retry` tool resolving its move target while still reporting
|
||||
`"todo"` in the log, the response text and `details.newColumn`. This CLI retry had the identical
|
||||
gap — same PR, same conversion, same three unconverted report sites — so it is fixed with it
|
||||
rather than waiting for the same comment on the next surface.
|
||||
|
||||
Resolve once, then use that value everywhere the operator or a downstream tool reads it.
|
||||
*/
|
||||
// FNXC:ToolPermissionGates 2026-07-26-13:55: user-facing retry move carries the user/hard-cancel source (Move-Task contract).
|
||||
await store.moveTask(params.id, 'todo', { moveSource: "user" });
|
||||
|
||||
const retryTarget = await fusionCore.resolveReboundTargetForTask(store, params.id);
|
||||
await store.moveTask(params.id, retryTarget, { moveSource: "user" });
|
||||
|
||||
// Log the retry action
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension", "Task reset to todo for retry");
|
||||
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension", `Task reset to ${retryTarget} for retry`);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Retried ${params.id} → todo (failure state cleared)` }],
|
||||
details: { taskId: params.id, newColumn: 'todo' },
|
||||
content: [{ type: "text", text: `Retried ${params.id} → ${retryTarget} (failure state cleared)` }],
|
||||
details: { taskId: params.id, newColumn: retryTarget },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -650,7 +650,7 @@ export async function updateTaskImpl(store: TaskStore,
|
||||
throw new Error(validation.message);
|
||||
}
|
||||
if (validation.requiresFinalize) {
|
||||
await store.moveTask(id, "done", {
|
||||
await store.moveTask(id, (await resolveTaskLifecycleColumns(store, id))?.complete ?? "done", {
|
||||
moveSource: "engine",
|
||||
recoveryRehome: true,
|
||||
preserveProgress: true,
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
AgentLogEntry,
|
||||
RunAuditEvent,
|
||||
} from "@fusion/core";
|
||||
import { AgentStore, ChatStore, queryRunAuditEvents, resolveGlobalDir, resolveProjectColumnsForRoles, REVIEW_ROLES, setRunningAgentCountSource } from "@fusion/core";
|
||||
import { AgentStore, ChatStore, queryRunAuditEvents, resolveGlobalDir, resolveProjectColumnsForRoles, resolveReboundTargetForTask, REVIEW_ROLES, setRunningAgentCountSource } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE, disconnectSSEClient, markSSEClientAlive } from "./sse.js";
|
||||
@@ -761,10 +761,19 @@ type CliRelaunchSessionStore = ServerOptions["cliSessionTransport"] extends infe
|
||||
: never
|
||||
: never;
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:59:
|
||||
`column` WIDENED from the literal `"todo"` to `string`, because the target is now resolved.
|
||||
|
||||
The narrow literal type was not a constraint anyone chose — it was inferred from the single call
|
||||
below, which passed `"todo"`. It then made the type system ENFORCE the bug: a resolved rebound target
|
||||
is a `string`, so the correct value could not be passed without this edit. A type that only admits the
|
||||
legacy id is a lint against fixing it.
|
||||
*/
|
||||
interface CliRelaunchTaskStoreLike {
|
||||
getTask(taskId: string): Promise<Task | null>;
|
||||
updateTask(taskId: string, patch: Record<string, unknown>): Promise<unknown>;
|
||||
moveTask(taskId: string, column: "todo", options?: Record<string, unknown>): Promise<unknown>;
|
||||
moveTask(taskId: string, column: string, options?: Record<string, unknown>): Promise<unknown>;
|
||||
logEntry(taskId: string, message: string, details?: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -804,7 +813,7 @@ export function wireCliRelaunchListener(options: {
|
||||
`CLI session relaunch requested from ${info.sessionId} — clearing resume linkage and re-enqueueing for a fresh executor run`,
|
||||
);
|
||||
await taskStore.updateTask(info.taskId, { paused: false, status: null, error: null });
|
||||
await taskStore.moveTask(info.taskId, "todo", {
|
||||
await taskStore.moveTask(info.taskId, await resolveReboundTargetForTask(taskStore as never, info.taskId), {
|
||||
preserveProgress: true,
|
||||
moveSource: "engine",
|
||||
recoveryRehome: true,
|
||||
|
||||
175
packages/engine/src/__tests__/no-legacy-move-targets.test.ts
Normal file
175
packages/engine/src/__tests__/no-legacy-move-targets.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// @vitest-environment node
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:59:
|
||||
A MOVE TARGET IS AN ARGUMENT, SO NO EXISTING GATE COUNTS IT.
|
||||
|
||||
The lifecycle-column census counts COMPARISONS against legacy ids. `moveTask(task.id, "todo")` contains
|
||||
no comparison, so the census reported zero while 25 such targets sat in `self-healing.ts` alone and 31
|
||||
across the tree.
|
||||
|
||||
The failure mode is worse than a stale guard's. `moveTaskInternal` REJECTS a target the workflow does
|
||||
not declare — `TransitionRejectionError: unknown-column` — which `task-store/moves.ts` documents after
|
||||
a completion handoff was found THROWING on every renamed board. A guard that fails to match degrades to
|
||||
"no rescue"; a target that throws is "no rescue, plus an exception in the sweep", and every one of these
|
||||
sites is a recovery path.
|
||||
|
||||
So this ratchet exists because the population was invisible, not because it was large. Fixing it once
|
||||
without a guard means it is invisible again the moment someone adds the next one.
|
||||
|
||||
WHAT IS AND IS NOT A TARGET. Only the SECOND argument of a `moveTask(...)` call counts. A legacy id as
|
||||
a FALLBACK (`resolveReboundTargetForTask` returning `"todo"`, or `lifecycle?.complete ?? "done"`) is
|
||||
the degraded answer every resolver in this program is required to have, and is not flagged — the
|
||||
resolver is what makes the call correct, and its fallback is what keeps default boards working.
|
||||
|
||||
BASELINE, NOT ZERO. Files outside the converted set keep their current counts so this can land without
|
||||
blocking other lanes; the ratchet fails on any INCREASE, and on a decrease that was not re-recorded —
|
||||
a stale allowance is a hole the same guards can regrow through.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../../../../..");
|
||||
|
||||
/** Legacy lifecycle column ids, as move TARGETS. */
|
||||
const LEGACY = "(?:todo|triage|in-progress|in-review|done|archived)";
|
||||
const MOVE_TARGET = new RegExp(`moveTask\\s*\\(\\s*[^,()]+,\\s*["']${LEGACY}["']`, "g");
|
||||
|
||||
/*
|
||||
Files that still hold literal targets, with the count each is allowed.
|
||||
|
||||
EMPTY, and that is the end state rather than a starting one: every `moveTask` call in the tree now
|
||||
resolves its target. It was populated when this guard was written — `cli/src/extension.ts`,
|
||||
`core/task-store/branch-and-pr-entities.ts`, `dashboard/src/server.ts` and `engine/agent-tools.ts`
|
||||
each held one — and the stale-allowance case below is what forced this map to be emptied when those
|
||||
four were converted in the same change. Leaving the entries behind would have left four slots open
|
||||
for the class to regrow through while the guard stayed green.
|
||||
|
||||
A NOTE FOR WHOEVER ADDS ONE. If a call genuinely cannot resolve its target, record it here with a
|
||||
comment saying why, rather than widening the matcher. The failure this guards is not "a literal
|
||||
appears" — it is `TransitionRejectionError: unknown-column` thrown at runtime on a renamed board.
|
||||
*/
|
||||
const ALLOWED: Record<string, number> = {};
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return execFileSync(
|
||||
"git",
|
||||
["ls-files", "packages/*/src/**/*.ts", "packages/*/src/*.ts", "packages/*/app/**/*.ts", "packages/*/app/**/*.tsx"],
|
||||
{ cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
||||
)
|
||||
.split("\n")
|
||||
.filter((f) => f && !f.includes("__tests__") && !/\.(test|spec)\.tsx?$/.test(f));
|
||||
}
|
||||
|
||||
/** Blank comments in place so prose describing a past call is not counted as one. */
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
||||
.replace(/\/\/[^\n]*/g, "");
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:59:
|
||||
MEMOISED for gate admission. Each case called this independently and every call re-read the whole
|
||||
corpus, so the scan ran five times for one tree: measured 660ms of the suite's 800ms. The tree cannot
|
||||
change mid-run, so the repeat reads bought nothing.
|
||||
|
||||
This matters because the gate's admission bar is about cost and determinism, and a guard that is
|
||||
gratuitously slow is a guard someone eventually moves back out of the gate.
|
||||
*/
|
||||
let countsCache: Record<string, number> | undefined;
|
||||
|
||||
function countByFile(): Record<string, number> {
|
||||
if (countsCache) return countsCache;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const file of sourceFiles()) {
|
||||
let source: string;
|
||||
try {
|
||||
source = readFileSync(resolve(REPO_ROOT, file), "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const hits = stripComments(source).match(MOVE_TARGET);
|
||||
if (hits && hits.length > 0) counts[file] = hits.length;
|
||||
}
|
||||
countsCache = counts;
|
||||
return counts;
|
||||
}
|
||||
|
||||
describe("moveTask targets resolve the board's own lane", () => {
|
||||
/*
|
||||
ANTI-VACUITY, and its first version was self-defeating: it asserted the scan finds at least one
|
||||
offender, which can only hold while the defect exists. Converting the last four sites turned it red
|
||||
— the guard failing precisely because the tree became correct.
|
||||
|
||||
A clean tree is the expected end state, so the proof has to be that the scan REACHES the right code
|
||||
rather than that it finds something wrong in it. Two independent legs, both of which break if the
|
||||
glob stops resolving or `moveTask` is renamed: the corpus is a real file list, and files that
|
||||
genuinely call `moveTask` are inside it. The matcher itself is covered by the case table below.
|
||||
*/
|
||||
it("scans a real corpus that still reaches the moveTask call sites", () => {
|
||||
const files = sourceFiles();
|
||||
expect(files.length).toBeGreaterThan(200);
|
||||
|
||||
const callers = files.filter((file) => {
|
||||
try {
|
||||
return /moveTask\s*\(/.test(stripComments(readFileSync(resolve(REPO_ROOT, file), "utf8")));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
expect(callers.length).toBeGreaterThan(5);
|
||||
expect(callers).toContain("packages/engine/src/self-healing.ts");
|
||||
});
|
||||
|
||||
/* The matcher is covered, because the scan is only as good as it: each case is a real shape. */
|
||||
it.each<[source: string, shouldFlag: boolean, why: string]>([
|
||||
['await store.moveTask(task.id, "todo");', true, "the plain form"],
|
||||
['await this.store.moveTask(taskId, "archived", { moveSource: "engine" });', true, "with options"],
|
||||
["await store.moveTask(id, 'done');", true, "single quotes"],
|
||||
['await store.moveTask(task.id, await resolveReboundTargetForTask(store, task.id));', false, "resolved"],
|
||||
['await store.moveTask(task.id, completeLane);', false, "resolved via a local"],
|
||||
['const target = lifecycle?.complete ?? "done";', false, "a FALLBACK is not a target"],
|
||||
['return resolveReboundTarget(ir) ?? "todo";', false, "a resolver's own degraded answer"],
|
||||
['if (task.column === "todo") return;', false, "a comparison — the census owns that class"],
|
||||
])("matcher: %s -> %s (%s)", (source, shouldFlag) => {
|
||||
expect(new RegExp(MOVE_TARGET.source).test(source)).toBe(shouldFlag);
|
||||
});
|
||||
|
||||
it("self-healing.ts has NO literal move targets", () => {
|
||||
/* The file this ratchet was written for: 25 sites, now zero. Asserted by name because a
|
||||
regression here is a recovery path that throws on a renamed board. */
|
||||
expect(countByFile()["packages/engine/src/self-healing.ts"] ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("no file exceeds its recorded allowance", () => {
|
||||
const counts = countByFile();
|
||||
const violations: string[] = [];
|
||||
for (const [file, count] of Object.entries(counts)) {
|
||||
const allowed = ALLOWED[file] ?? 0;
|
||||
if (count > allowed) violations.push(`${file}: ${count} literal move target(s), allowed ${allowed}`);
|
||||
}
|
||||
expect(
|
||||
violations,
|
||||
"A moveTask target the workflow does not declare is REJECTED (TransitionRejectionError: "
|
||||
+ "unknown-column), so this throws on a renamed board rather than degrading.\n"
|
||||
+ "Resolve the target (resolveReboundTargetForTask / resolveArchiveTargetForTask /\n"
|
||||
+ "resolveTaskLifecycleColumns) — a legacy id is fine as the resolver's FALLBACK, not as the "
|
||||
+ "argument:\n" + violations.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("no allowance is stale", () => {
|
||||
/* A recorded allowance that exceeds the tree is a hole the same targets can regrow through —
|
||||
the same reason the census baseline fails on an unrecorded DROP. */
|
||||
const counts = countByFile();
|
||||
const stale = Object.entries(ALLOWED)
|
||||
.filter(([file, allowed]) => (counts[file] ?? 0) < allowed)
|
||||
.map(([file, allowed]) => `${file}: allows ${allowed}, tree has ${counts[file] ?? 0}`);
|
||||
expect(stale, `Lower the allowance to match the tree:\n${stale.join("\n")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:59:
|
||||
THE REBOUND TARGET, not the guard that selects the card.
|
||||
|
||||
`self-healing.ts` held 18 `moveTask(task.id, "todo", …)` calls — every one a RECOVERY. A move target
|
||||
is an ARGUMENT, not a comparison, so the lifecycle-column census never counted them and no gate ever
|
||||
pointed here. The failure mode is also harder than a guard's: `moveTaskInternal` REJECTS a target the
|
||||
workflow does not declare (`TransitionRejectionError: unknown-column`, documented in
|
||||
`task-store/moves.ts`). So on a renamed board these sweeps did not degrade to "no rescue" — they threw,
|
||||
and the strand each sweep exists to clear survived while the sweep reported failure.
|
||||
|
||||
`reconcileInReviewUnmetDependencies` is driven here as the representative: it is a public entry point
|
||||
with a documented contract (FN-6793), and its rebound is one of the 18.
|
||||
|
||||
WHY THE DEFAULT-BOARD CASE IS THE CONTROL AND NOT AN AFTERTHOUGHT. `resolveReboundTargetForTask`
|
||||
degrades to `"todo"` whenever the workflow declares no hold/intake lane, so the conversion is a no-op
|
||||
on every board we ship. The control pins that; without it a regression that made the resolver always
|
||||
answer `"todo"` would leave the renamed case failing and look like a fixture problem.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { Task, TaskStore, WorkflowIr } from "@fusion/core";
|
||||
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
|
||||
const WF = "custom:wf";
|
||||
|
||||
/** An in-review card whose dependency is unmet — the FN-6793 rebound case. */
|
||||
function inReviewTask(column: string): Task {
|
||||
return {
|
||||
id: "FN-DEP",
|
||||
title: "t",
|
||||
description: "",
|
||||
column,
|
||||
dependencies: ["FN-BLOCKER"],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
/** The blocker it depends on, still unfinished, so the dependency stays unmet. */
|
||||
function blockerTask(column: string): Task {
|
||||
return {
|
||||
id: "FN-BLOCKER",
|
||||
title: "blocker",
|
||||
description: "",
|
||||
column,
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
/** A board whose hold lane is `drafting` and whose review lane is `checking`. */
|
||||
function renamedIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
id: WF,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
columns: [
|
||||
{ id: "drafting", label: "Drafting", traits: [{ trait: "hold", config: { release: "capacity" } }] },
|
||||
{ id: "building", label: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "checking", label: "Checking", traits: [{ trait: "merge" }] },
|
||||
{ id: "shipped", label: "Shipped", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
} as unknown as WorkflowIr;
|
||||
}
|
||||
|
||||
function createStore(tasks: Task[], workflowIr: WorkflowIr | undefined) {
|
||||
const moveTask = vi.fn(async (_id: string, _column: string) => tasks[0]);
|
||||
const selection = { workflowId: WF, stepIds: [] };
|
||||
const store = {
|
||||
getSettings: vi.fn().mockResolvedValue({ autoMerge: true }),
|
||||
listTasks: vi.fn(async (opts?: { column?: string }) =>
|
||||
opts?.column ? tasks.filter((t) => t.column === opts.column) : tasks,
|
||||
),
|
||||
getTask: vi.fn(async (id: string) => tasks.find((t) => t.id === id) ?? null),
|
||||
moveTask,
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
getCompletionHandoffAcceptedMarker: vi.fn().mockResolvedValue(null),
|
||||
getTaskWorkflowSelection: vi.fn(() => selection),
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
|
||||
getWorkflowDefinition: vi.fn(async () => (workflowIr ? { ir: workflowIr } : null)),
|
||||
/* The sweep selects rows with `resolveProjectColumnsForRoles`, which reads the PROJECT's workflow
|
||||
definitions rather than the task's own selection. Without this the renamed card is never even
|
||||
considered, and the test fails upstream of the target it is about. */
|
||||
listWorkflowDefinitions: vi.fn(async () => (workflowIr ? [{ ir: workflowIr }] : [])),
|
||||
} as unknown as TaskStore;
|
||||
return { store, moveTask };
|
||||
}
|
||||
|
||||
function manager(store: TaskStore) {
|
||||
return new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
}
|
||||
|
||||
describe("the self-healing rebound TARGET follows the board's own hold lane", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
/*
|
||||
The defect: the target was the literal `"todo"`, which this board does not declare, so the move was
|
||||
rejected outright rather than rebounding the card. `drafting` collides with no legacy id, so a
|
||||
surviving literal cannot pass by luck.
|
||||
*/
|
||||
it("rebounds an in-review card with unmet dependencies to the RENAMED hold lane", async () => {
|
||||
const tasks = [inReviewTask("checking"), blockerTask("building")];
|
||||
const { store, moveTask } = createStore(tasks, renamedIr());
|
||||
|
||||
await manager(store).reconcileInReviewUnmetDependencies();
|
||||
|
||||
expect(moveTask).toHaveBeenCalled();
|
||||
expect(moveTask.mock.calls[0]?.[1]).toBe("drafting");
|
||||
});
|
||||
|
||||
/*
|
||||
CONTROL. `resolveReboundTargetForTask` falls back to `"todo"` when no workflow resolves, so the
|
||||
default board must be byte-identical to the pre-conversion behaviour. This is what makes the
|
||||
conversion safe to land across 18 recovery paths at once.
|
||||
*/
|
||||
it("default vocabulary: still rebounds to `todo` when no workflow resolves", async () => {
|
||||
const tasks = [inReviewTask("in-review"), blockerTask("in-progress")];
|
||||
const { store, moveTask } = createStore(tasks, undefined);
|
||||
|
||||
await manager(store).reconcileInReviewUnmetDependencies();
|
||||
|
||||
expect(moveTask).toHaveBeenCalled();
|
||||
expect(moveTask.mock.calls[0]?.[1]).toBe("todo");
|
||||
});
|
||||
});
|
||||
@@ -3206,9 +3206,21 @@ export function createTaskRetryTool(store: TaskStore): ToolDefinition {
|
||||
return { content: [{ type: "text" as const, text: `Task ${params.id} is not in a retryable state (status: ${task.status || "none"})` }], details: { taskId: params.id, currentStatus: task.status }, isError: true };
|
||||
}
|
||||
await store.updateTask(params.id, { status: null, error: null });
|
||||
await store.moveTask(params.id, "todo");
|
||||
await store.logEntry(params.id, "Retry requested via chat tool", "Task reset to todo for retry");
|
||||
return { content: [{ type: "text" as const, text: `Retried ${params.id} → todo` }], details: { taskId: params.id, newColumn: "todo" } };
|
||||
/*
|
||||
FNXC:TaskRetry 2026-07-31-23:59 (review finding on #3152 — the move resolved, the REPORT did not):
|
||||
The rebound target is resolved once and reused for the move, the log line, the response text
|
||||
and `details.newColumn`. Converting only the `moveTask` argument left three places still
|
||||
naming `"todo"`, so on a renamed board the card correctly landed in (say) `backlog` while the
|
||||
operator and the task log were both told it went to `todo` — a lie that is worse than the
|
||||
original literal, because the original at least agreed with itself.
|
||||
|
||||
`details.newColumn` is the one that travels: it is machine-readable output other tooling can
|
||||
act on, so a wrong value there is not merely cosmetic.
|
||||
*/
|
||||
const retryTarget = await fusionCore.resolveReboundTargetForTask(store, params.id);
|
||||
await store.moveTask(params.id, retryTarget);
|
||||
await store.logEntry(params.id, "Retry requested via chat tool", `Task reset to ${retryTarget} for retry`);
|
||||
return { content: [{ type: "text" as const, text: `Retried ${params.id} → ${retryTarget}` }], details: { taskId: params.id, newColumn: retryTarget } };
|
||||
} catch (err: unknown) {
|
||||
return { content: [{ type: "text" as const, text: `ERROR: Failed to retry task: ${toolErrorMessage(err)}` }], details: {}, isError: true };
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync,
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
|
||||
import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, resolveReboundTargetForTask, resolveArchiveTargetForTask, columnsWithFlag, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr,
|
||||
LEGACY_COLUMN_IDS_BY_ROLE,
|
||||
TERMINAL_ROLES,
|
||||
resolveProjectColumnsForRoles,
|
||||
@@ -251,7 +251,7 @@ export async function archiveAsGhostBug(
|
||||
});
|
||||
// #1411: recovery/terminal move — recoveryRehome skips order-derived adjacency
|
||||
// so a custom-workflow card can always reach the terminal column.
|
||||
await store.moveTask(taskId, "archived", { moveSource: "engine", recoveryRehome: true });
|
||||
await store.moveTask(taskId, await resolveArchiveTargetForTask(store, taskId), { moveSource: "engine", recoveryRehome: true });
|
||||
}
|
||||
|
||||
async function classifyOwnedLandedEvidenceForSelfHealing(rootDir: string, task: Task, mergeTargetBranch: string): Promise<OwnedLandedClassification> {
|
||||
@@ -2053,7 +2053,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
|
||||
await this.store.updateTask(taskId, parkUpdate);
|
||||
try {
|
||||
await this.store.moveTask(taskId, "todo", {
|
||||
await this.store.moveTask(taskId, await resolveReboundTargetForTask(this.store, taskId), {
|
||||
preserveProgress: true,
|
||||
preserveStatus: true,
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
@@ -3800,7 +3800,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
status: null,
|
||||
error: null,
|
||||
});
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
moveSource: "engine",
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
recoveryRehome: true,
|
||||
@@ -4088,7 +4088,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
signalReason: liveExecutionSignal.reason,
|
||||
},
|
||||
});
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
moveSource: "engine",
|
||||
recoveryRehome: true,
|
||||
preserveProgress: true,
|
||||
@@ -4263,7 +4263,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (!reviewProof?.ok) {
|
||||
await this.emitBackwardMoveNoAction(task, "reclaim-self-owned-branch-conflict", "task:reclaim-self-owned-branch-conflict-no-action", reviewProof!);
|
||||
} else {
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
moveSource: "engine",
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
recoveryRehome: true,
|
||||
@@ -4368,7 +4368,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (!reviewProof?.ok) {
|
||||
await this.emitBackwardMoveNoAction(task, "reclaim-self-owned-branch-conflict", "task:reclaim-self-owned-branch-conflict-no-action", reviewProof!);
|
||||
} else {
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
moveSource: "engine",
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
recoveryRehome: true,
|
||||
@@ -4463,7 +4463,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const idleAnchor = task.executionStartedAt ?? task.columnMovedAt ?? task.updatedAt;
|
||||
const idleAnchorMs = Date.parse(idleAnchor ?? "");
|
||||
const idleMs = Number.isFinite(idleAnchorMs) ? Math.max(0, Date.now() - idleAnchorMs) : null;
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
moveSource: "engine",
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
recoveryRehome: true,
|
||||
@@ -4532,7 +4532,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (!reviewProof?.ok) {
|
||||
await this.emitBackwardMoveNoAction(task, "reclaim-self-owned-branch-conflict", "task:reclaim-self-owned-branch-conflict-no-action", reviewProof!);
|
||||
} else {
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
moveSource: "engine",
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
recoveryRehome: true,
|
||||
@@ -5619,7 +5619,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
@@ -6416,7 +6416,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.store.moveTask(holder.id, "todo", {
|
||||
await this.store.moveTask(holder.id, await resolveReboundTargetForTask(this.store, holder.id), {
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
@@ -6679,7 +6679,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), {
|
||||
preserveProgress: true,
|
||||
preserveWorktree: true,
|
||||
preserveResumeState: true,
|
||||
@@ -7768,7 +7768,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
continue;
|
||||
}
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -7831,7 +7831,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
lane: "self-healing-finalize-no-op-review",
|
||||
});
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
recovered++;
|
||||
continue;
|
||||
}
|
||||
@@ -7851,7 +7851,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
baseRef: classification.baseRef,
|
||||
});
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
recovered++;
|
||||
continue;
|
||||
}
|
||||
@@ -7876,7 +7876,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
await this.store.logEntry(task.id, `Auto-finalized no-op (proven): start point on ${classification.baseRef}; modifiedFiles cleared`);
|
||||
}
|
||||
|
||||
const movedTask = await this.store.moveTask(task.id, "done");
|
||||
const completeLane = (await resolveTaskLifecycleColumns(this.store, task.id))?.complete ?? "done";
|
||||
const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
|
||||
recovered++;
|
||||
}
|
||||
@@ -8635,7 +8636,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
"Auto-recovered: in-review task still had incomplete steps — moved back to todo for retry",
|
||||
);
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
log.log(`Recovered stale incomplete review task ${task.id}: moved back to todo`);
|
||||
recovered++;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -9123,7 +9124,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
"Auto-recovered: in-review task idle past stuck-task timeout — kicked back to todo",
|
||||
);
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
log.log(`Kicked ghost review task ${task.id} back to todo`);
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
@@ -9517,7 +9518,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
mergeDetails,
|
||||
});
|
||||
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-interrupted-merging");
|
||||
const movedTask = await this.store.moveTask(task.id, "done");
|
||||
const completeLane = (await resolveTaskLifecycleColumns(this.store, task.id))?.complete ?? "done";
|
||||
const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
|
||||
await this.cleanupInterruptedMergeArtifacts(task);
|
||||
await this.store.logEntry(
|
||||
@@ -10722,7 +10724,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
mergeDetails,
|
||||
});
|
||||
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-stuck-merge-deadlocks");
|
||||
const movedTask = await this.store.moveTask(task.id, "done");
|
||||
const completeLane = (await resolveTaskLifecycleColumns(this.store, task.id))?.complete ?? "done";
|
||||
const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
|
||||
await this.cleanupInterruptedMergeArtifacts(task);
|
||||
|
||||
@@ -10962,7 +10965,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
mergeDetails,
|
||||
});
|
||||
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-orphan-only-scope-violations");
|
||||
const movedTask = await this.store.moveTask(task.id, "done");
|
||||
const completeLane = (await resolveTaskLifecycleColumns(this.store, task.id))?.complete ?? "done";
|
||||
const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
@@ -11226,7 +11230,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
});
|
||||
const worktreeHint = task.worktree;
|
||||
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-already-merged-review");
|
||||
const movedTask = await this.store.moveTask(task.id, "done");
|
||||
const completeLane = (await resolveTaskLifecycleColumns(this.store, task.id))?.complete ?? "done";
|
||||
const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
@@ -11876,7 +11881,8 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
// merger.ts completeTask() and project-engine.ts auto-merge already-confirmed path.
|
||||
await this.store.updateTask(task.id, { status: null, error: null, paused: false });
|
||||
await this.recordSelfHealingBranchGroupMemberLanding(task, mergeTarget, "recover-branch-misbound-in-review");
|
||||
const movedTask = await this.store.moveTask(task.id, "done");
|
||||
const completeLane = (await resolveTaskLifecycleColumns(this.store, task.id))?.complete ?? "done";
|
||||
const movedTask = await this.store.moveTask(task.id, completeLane);
|
||||
this.emitTaskMerged(movedTask, { mergeConfirmed: true });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
@@ -12767,7 +12773,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
},
|
||||
});
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -13848,7 +13854,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
"Auto-recovered no-progress no-task_done failure — clean worktree, moved back to todo",
|
||||
);
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { moveSource: "engine", recoveryRehome: true });
|
||||
recovered++;
|
||||
} catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`Failed to recover no-progress no-task_done failure ${task.id}: ${errorMessage}`);
|
||||
@@ -14137,7 +14143,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
`Auto-retry ${nextCount}/${MAX_TASK_DONE_RETRIES}: agent finished without fn_task_done — requeuing to todo to resume partial work`,
|
||||
);
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
await this.store.moveTask(task.id, await resolveReboundTargetForTask(this.store, task.id), { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
recovered++;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -200,6 +200,28 @@ export default defineConfig({
|
||||
same number.
|
||||
*/
|
||||
"src/__tests__/legacy-column-literal-census.test.ts",
|
||||
/*
|
||||
FNXC:EngineTests 2026-07-31-23:59:
|
||||
THE MOVE-TARGET RATCHET BELONGS HERE FOR THE SAME REASON THE CENSUS RATCHET ABOVE DOES,
|
||||
and the argument is stronger for this one.
|
||||
|
||||
The census counts COMPARISONS; a move target is an ARGUMENT, so nothing above sees it. And
|
||||
the failure mode is harder than a stale guard's: `moveTaskInternal` REJECTS a target the
|
||||
workflow does not declare (`TransitionRejectionError: unknown-column`), so a regression
|
||||
here THROWS on a renamed board instead of degrading — in recovery paths, which are the
|
||||
ones that run when something has already gone wrong.
|
||||
|
||||
Outside the gate this repeats the pattern this program keeps paying for: a correct signal
|
||||
that fires in a non-blocking run while the PR merges anyway. That happened to my own
|
||||
#3114 this week — `check-inert-sync-lanes` flagged it correctly and it landed regardless.
|
||||
|
||||
Admission evidence, on the same terms as the entries around it: pure computation over one
|
||||
`git ls-files` plus file reads. No store, no network, no timers, no subprocess beyond that
|
||||
one call. Deterministic by construction — same tree, same counts. Measured 509ms / 508ms
|
||||
across runs, 12 tests, after memoising the corpus scan (it was 800ms when each case
|
||||
re-read every file).
|
||||
*/
|
||||
"src/__tests__/no-legacy-move-targets.test.ts",
|
||||
"src/__tests__/merger-merge-lifecycle.test.ts",
|
||||
"src/__tests__/merger-conflict-resolution.test.ts",
|
||||
"src/__tests__/merger-diff-scope.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user