fix(engine): a second complete lane is terminal too — restore the scheduler's dependency reconciliation (main is red) (#3065)
## main is red, and this is the fix ``` FAIL src/__tests__/scheduler-renamed-hold-events.test.ts > dependency unblocking (failure mode is a card that waits forever) > finds dependents resting in the renamed hold column when a blocker completes AssertionError: expected [] to include 'drafting' ``` Not in the thin merge gate's `engine-core` allow-list, so CI stayed green and only the non-blocking full suite sees it. The test file is unchanged since #2518; #3051 converted the guard underneath it. ## What broke #3051 turned the guard into `to === parked.complete || to === parked.archived`. `resolveLifecycleColumns` answers **first match per role** — the right shape for a move *target*, the wrong shape for *"did this card just reach a finished lane"*, which is a membership question. Two consequences, both silent: 1. **A board with more than one complete-trait column reconciles nothing** when a blocker finishes in the second one. The test file's own header flags this path specifically: *"This one is NOT latency: a dependent never gets unblocked, so it waits on a blocker that is already done."* 2. The legacy `done`/`archived` ids stopped matching at all — the failing assertion. ## Fix `resolveTaskParkedColumnsSync` gains `terminal`, a membership set: legacy `done`/`archived` seeded, then **every** complete- and archived-trait column from the task's own IR. Seeding legacy ids is safe in the direction that matters here. This is an **inclusion**: a superset makes the reconciliation run on a move it would otherwise ignore — one extra query, and it cannot wrongly withhold work. Seeding a **refusal** is the bug (`node-override-guard.ts` documents that one); this is not that. Same sync IR path and same fail-soft legacy default as the single-column answers, so event ordering and unresolvable-workflow behaviour are unchanged — the constraint the sync resolver's own header sets. The two sibling guards in the same listener (dispatch-oscillation reset at what is now line 1097, and the scheduling wake at 1114) had the identical arity defect and convert with it. ## Measured | | result | |---|---| | before | `scheduler-renamed-hold-events`: **1 failed / 9 passed** | | after | **11 passed** (one new case) | | `src/__tests__/scheduler*` | **14 files / 143 tests pass** | | `tsc --noEmit -p packages/engine` | clean | | census `--strict` / `check-lane-wiring` / `check-fnxc-future-dates` | clean, no baseline movement | **Proved it is main's red, not my branch's:** I checked out `origin/main:packages/engine/src/self-healing.ts` over my unrelated fleet branch and re-ran — identical failure. Then branched this fix straight off `origin/main`. **Mutation-tested.** Restoring `to === parked.complete || to === parked.archived` fails **both** the pre-existing case and the new second-complete-lane case. The new test is not vacuous: the second complete lane is invisible to first-match resolution, so it cannot pass against the old guard. ## Not done here I did not convert the remaining `to === parked.review` / `from === parked.wip` single-column comparisons in this listener. Review is genuinely two roles (`mergeBlocker` + `humanReview`) and wip has its own limit-setting semantics — both want the same membership-vs-target judgement applied deliberately rather than swept in behind a red-fix. Flagged, not guessed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,7 @@ function renamedIr(): WorkflowIr {
|
||||
} as unknown as WorkflowIr;
|
||||
}
|
||||
|
||||
function createStore(tasks: Record<string, unknown>[] = []) {
|
||||
function createStore(tasks: Record<string, unknown>[] = [], ir: WorkflowIr = renamedIr()) {
|
||||
const listeners = new Map<string, ((payload: unknown) => void)[]>();
|
||||
const selection = { workflowId: WF, stepIds: [] };
|
||||
const listTasks = vi.fn(async (opts?: { column?: string }) =>
|
||||
@@ -73,8 +73,8 @@ function createStore(tasks: Record<string, unknown>[] = []) {
|
||||
getCompletionHandoffAcceptedMarker: vi.fn().mockResolvedValue(null),
|
||||
getTaskWorkflowSelection: vi.fn(() => selection),
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => selection),
|
||||
getWorkflowDefinition: vi.fn(async () => ({ ir: renamedIr() })),
|
||||
resolveTaskWorkflowIrSync: vi.fn(() => renamedIr()),
|
||||
getWorkflowDefinition: vi.fn(async () => ({ ir })),
|
||||
resolveTaskWorkflowIrSync: vi.fn(() => ir),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
return {
|
||||
@@ -130,8 +130,9 @@ function createAgentStore(agents: Record<string, unknown>[], freshRun: unknown =
|
||||
function createScheduler(
|
||||
tasks: Record<string, unknown>[] = [],
|
||||
options: Record<string, unknown> = {},
|
||||
ir: WorkflowIr = renamedIr(),
|
||||
) {
|
||||
const { store, emit, listTasks } = createStore(tasks);
|
||||
const { store, emit, listTasks } = createStore(tasks, ir);
|
||||
const scheduler = new Scheduler(store, options as never);
|
||||
const schedule = vi.spyOn(scheduler, "schedule").mockResolvedValue(undefined);
|
||||
(scheduler as unknown as { running: boolean }).running = true;
|
||||
@@ -193,6 +194,32 @@ describe("scheduler event handlers under a renamed hold column", () => {
|
||||
expect(queried).not.toContain("todo");
|
||||
expect(queried).toContain("drafting");
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-12:40:
|
||||
THE SECOND COMPLETE LANE. The guard above used to ask `to === parked.complete`, and
|
||||
`resolveLifecycleColumns` answers FIRST MATCH PER ROLE — so on a board that declares two
|
||||
complete-trait columns, a blocker finishing in the second one reconciled nothing and its
|
||||
dependents waited forever on a blocker that was already done.
|
||||
|
||||
That arity difference is invisible on a single-lane board, which is why the sibling case above
|
||||
passes either way and this one is needed to hold the membership shape in place.
|
||||
*/
|
||||
it("treats a SECOND complete-trait column as terminal, not just the first", async () => {
|
||||
const twoCompleteLanes = renamedIr();
|
||||
(twoCompleteLanes as unknown as { columns: Record<string, unknown>[] }).columns.push({
|
||||
id: "released", name: "released", traits: [{ trait: "complete" }],
|
||||
});
|
||||
|
||||
const dependent = task({ id: "FN-DEP", column: "drafting", dependencies: ["FN-BLOCK"], blockedBy: "FN-BLOCK" });
|
||||
const blocker = task({ id: "FN-BLOCK", column: "released" });
|
||||
const { emit, listTasks } = createScheduler([dependent, blocker], {}, twoCompleteLanes);
|
||||
|
||||
await emit("task:moved", { task: blocker, from: "building", to: "released", source: "engine" });
|
||||
|
||||
const queried = listTasks.mock.calls.map((c) => (c[0] as { column?: string } | undefined)?.column);
|
||||
expect(queried).toContain("drafting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent link (wrong here DROPS a live agent's task link)", () => {
|
||||
|
||||
@@ -409,13 +409,39 @@ terminal cleanup never ran. None of those error; they simply stop happening. One
|
||||
event, reusing the SAME sync path and the SAME fail-soft legacy defaults, so event ordering and
|
||||
unresolvable-workflow behaviour are both unchanged.
|
||||
*/
|
||||
function resolveTaskParkedColumnsSync(store: TaskStore, taskId: string): { hold: string; intake: string; wip: string; review: string; complete: string; archived: string } {
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-12:30:
|
||||
`terminal` is a MEMBERSHIP set, and it is not the same question as `complete`/`archived`.
|
||||
|
||||
`resolveLifecycleColumns` answers FIRST MATCH PER ROLE — the right shape for "where should this card
|
||||
be moved to", the wrong shape for "did this card just reach a finished lane". A workflow may declare
|
||||
more than one complete-trait column (a merged lane and a shipped lane, say); `to === parked.complete`
|
||||
sees only the first and silently skips the rest.
|
||||
|
||||
Seeded with the legacy ids. For an INCLUSION that is safe in the direction that matters: a superset
|
||||
makes the reconciliation below run on a move it would otherwise ignore, which costs one extra query
|
||||
and cannot wrongly withhold work. (Seeding a REFUSAL is the bug — see `node-override-guard.ts`.)
|
||||
|
||||
Same sync IR path and same fail-soft legacy default as the single-column answers, so event ordering
|
||||
and unresolvable-workflow behaviour are unchanged.
|
||||
*/
|
||||
function resolveTaskParkedColumnsSync(store: TaskStore, taskId: string): { hold: string; intake: string; wip: string; review: string; complete: string; archived: string; terminal: ReadonlySet<string> } {
|
||||
const legacy = { hold: "todo", intake: "triage", wip: "in-progress", review: "in-review", complete: "done", archived: "archived" };
|
||||
const legacyTerminal: ReadonlySet<string> = new Set([legacy.complete, legacy.archived]);
|
||||
try {
|
||||
const l = resolveLifecycleColumns(store.resolveTaskWorkflowIrSync(taskId));
|
||||
return { hold: l?.hold ?? legacy.hold, intake: l?.intake ?? legacy.intake, wip: l?.wip ?? legacy.wip, review: l?.review ?? legacy.review, complete: l?.complete ?? legacy.complete, archived: l?.archived ?? legacy.archived };
|
||||
const ir = store.resolveTaskWorkflowIrSync(taskId);
|
||||
const l = resolveLifecycleColumns(ir);
|
||||
return {
|
||||
hold: l?.hold ?? legacy.hold,
|
||||
intake: l?.intake ?? legacy.intake,
|
||||
wip: l?.wip ?? legacy.wip,
|
||||
review: l?.review ?? legacy.review,
|
||||
complete: l?.complete ?? legacy.complete,
|
||||
archived: l?.archived ?? legacy.archived,
|
||||
terminal: new Set([...legacyTerminal, ...columnsWithFlag(ir, "complete"), ...columnsWithFlag(ir, "archived")]),
|
||||
};
|
||||
} catch {
|
||||
return legacy;
|
||||
return { ...legacy, terminal: legacyTerminal };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,7 +1015,7 @@ export class Scheduler {
|
||||
// FN-3895/FN-3924: complement periodic stale-blockedBy self-healing with immediate
|
||||
// blocker reconciliation when a potential blocker reaches a terminal completion column.
|
||||
// Invariant: blockedBy must reference a *current* unresolved blocker, else be null.
|
||||
if (to === parked.complete || to === parked.archived) {
|
||||
if (parked.terminal.has(to)) {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (!settings.globalPause && !settings.enginePaused) {
|
||||
@@ -1068,7 +1094,7 @@ export class Scheduler {
|
||||
} else {
|
||||
this.recentEngineTodoRequeues.delete(task.id);
|
||||
}
|
||||
} else if (to === parked.review || to === parked.complete || to === parked.archived) {
|
||||
} else if (to === parked.review || parked.terminal.has(to)) {
|
||||
this.recentEngineTodoRequeues.delete(task.id);
|
||||
if (task.dispatchStormCount != null || task.lastDispatchAt != null || task.executeRequeueLoopCount != null || task.executeRequeueLoopSignature != null) {
|
||||
void this.store.updateTask(task.id, {
|
||||
@@ -1085,7 +1111,7 @@ export class Scheduler {
|
||||
// Event-driven scheduling: when a task moves to "done" (completion) or "todo" (retry/manual move),
|
||||
// trigger scheduling immediately so waiting tasks can start without waiting
|
||||
// for the next poll interval (up to 15 seconds).
|
||||
if (to === parked.complete || to === parked.hold) {
|
||||
if (parked.terminal.has(to) || to === parked.hold) {
|
||||
schedulerLog.log(`Task moved to ${to} — triggering scheduling`);
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user