fix(events): carry resolved lanes on task:moved so listener guards stop being inert (#3109)
Removes the **inert-guard class at its source** instead of one call site at a time. Independent of my other branches. ## The problem `task:moved` listeners run synchronously, so a listener needing a lane answer had to resolve one synchronously — and `resolveTaskWorkflowIrSync` returns the **default** workflow under PostgreSQL, the shipped backend. Every such guard behaved exactly as the literal it replaced, while the census scored it as converted. **Resolving asynchronously inside the listener is not available**, and that is measured rather than assumed. The scheduler's `snapshotManager.invalidate` is asserted to run in the listener's **synchronous prologue**; putting an await ahead of it produced **3 failures across 21 scheduler suites**. ## The fix The emitter carries the answer, which removes the dilemma rather than trading one horn for the other. `moves.ts` is already async and already post-commit, so it resolves the moving task's lanes **once** and hands them to every listener. The guard becomes correct **and** the prologue stays synchronous. This is the file's own recorded preferred fix — *"having the emitter carry the resolved lanes on the event payload so no listener resolves at all"* — now that the audit it was waiting on is done and came back as **one** prologue-dependent consumer, not a class. ## Design choices - **`lanes` is optional and fail-soft to `undefined`** — "unknown", never "legacy". Some emit paths fire from sync contexts or a cached row mid-teardown. Listeners keep their existing fallback, so those paths are no better than before but **no worse**, and they become the exception rather than the rule. - **`mergeParkedColumns` overlays only fields the emitter actually resolved**, so a partial payload cannot blank a lane back to a wrong answer. - **The sync resolver stays** as that fallback. Deleting it would strand the emit paths that cannot resolve. ## Verification - **Revert-proof and it pins the prologue:** the new case asserts invalidation on a **renamed** hold lane with **no `waitFor`**. Ignoring the payload gives **0 calls**. - 21 scheduler suites — **361 green** - self-healing + notification suites — **491 green** - core moves + the `sync-workflow-ir-callsite-allowlist` ratchet — green - **`pnpm test:gate` green** (71) - Changeset added; `check:changesets` passes ## What it unblocks `scheduler.ts`'s 10 allow-listed guards now resolve correctly for every move that goes through `moves.ts` — the path real moves take. Those were already absent from the backlog, so **the census number does not move**; what changes is that they now do what the number claimed. `executor.ts`'s 4 remaining sites can follow the same pattern in a separate PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,33 @@ describe("Scheduler auto-claim snapshot invalidation", () => {
|
||||
expect(internals.wasNodeBlocked.has("FN-1")).toBe(false);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-21:00 (fleet):
|
||||
The lane guard in this listener used to resolve through `resolveTaskWorkflowIrSync`, which returns the
|
||||
DEFAULT workflow in production — so on a renamed board it matched nothing and the auto-claim snapshot
|
||||
was never invalidated, silently serving stale claim data.
|
||||
|
||||
It could not simply become async: the assertion below pins `invalidate` to the listener's SYNCHRONOUS
|
||||
prologue, and an await ahead of it fails that test. The emitter now resolves the lanes and carries them
|
||||
on the payload, so the guard is correct AND the prologue stays synchronous — which is exactly what this
|
||||
case checks, by using a hold lane that matches no legacy id.
|
||||
*/
|
||||
it("invalidates on a RENAMED hold lane using the lanes the emitter resolved", () => {
|
||||
const invalidate = vi.fn();
|
||||
const { store, emit } = createStore();
|
||||
new Scheduler(store, { snapshotManager: { invalidate } as any });
|
||||
|
||||
emit("task:moved", {
|
||||
task: createTask({ id: "FN-9", column: "building" }),
|
||||
from: "backlog",
|
||||
to: "building",
|
||||
lanes: { hold: "backlog", wip: "building" },
|
||||
});
|
||||
|
||||
// Synchronous on purpose: no waitFor. The prologue must still run before emit returns.
|
||||
expect(invalidate).toHaveBeenCalledWith("task:moved:backlog->building");
|
||||
});
|
||||
|
||||
it("invalidates task:moved only when todo is source or destination", () => {
|
||||
const invalidate = vi.fn();
|
||||
const { store, emit } = createStore();
|
||||
|
||||
@@ -40,6 +40,7 @@ import { StaleTaskReporter } from "./stale-task-reporter.js";
|
||||
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
|
||||
import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
import type { TaskMoveLanes } from "@fusion/core";
|
||||
import { resolveProjectColumnsForRoles, resolveWorkflowIrForTask, resolveWorkflowIrById, resolveColumnFlags, resolveWorktreeCapacityLimit, resolveLifecycleColumns, isWipColumnRole, isReviewColumnRole, isCompleteColumnRole, columnsWithFlag } from "@fusion/core";
|
||||
import type { ColumnRoleTraitFlags } from "@fusion/core";
|
||||
import type { WorkflowIr, WorkflowIrV2 } from "@fusion/core";
|
||||
@@ -425,6 +426,29 @@ and cannot wrongly withhold work. (Seeding a REFUSAL is the bug — see `node-ov
|
||||
Same sync IR path and same fail-soft legacy default as the single-column answers, so event ordering
|
||||
and unresolvable-workflow behaviour are unchanged.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-21:00 (fleet):
|
||||
Overlay emitter-resolved lanes onto the fail-soft defaults. Only fields the emitter actually resolved
|
||||
are taken, so a partial payload cannot blank a lane back to a wrong answer.
|
||||
*/
|
||||
function mergeParkedColumns(
|
||||
base: { hold: string; intake: string; wip: string; review: string; complete: string; archived: string; terminal: ReadonlySet<string> },
|
||||
lanes: TaskMoveLanes | undefined,
|
||||
): { hold: string; intake: string; wip: string; review: string; complete: string; archived: string; terminal: ReadonlySet<string> } {
|
||||
if (!lanes) return base;
|
||||
const complete = lanes.complete ?? base.complete;
|
||||
const archived = lanes.archived ?? base.archived;
|
||||
return {
|
||||
hold: lanes.hold ?? base.hold,
|
||||
intake: lanes.intake ?? base.intake,
|
||||
wip: lanes.wip ?? base.wip,
|
||||
review: lanes.review ?? base.review,
|
||||
complete,
|
||||
archived,
|
||||
terminal: new Set([complete, archived]),
|
||||
};
|
||||
}
|
||||
|
||||
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]);
|
||||
@@ -960,9 +984,23 @@ export class Scheduler {
|
||||
rather than one instance — having the emitter carry the resolved lanes on the event payload so
|
||||
no listener resolves at all.
|
||||
*/
|
||||
this.store.on("task:moved", async ({ task, from, to, source }) => {
|
||||
this.store.on("task:moved", async ({ task, from, to, source, lanes }) => {
|
||||
this.lastAutoClaimFingerprint.set(task.id, computeAutoClaimFingerprint(task));
|
||||
const parked = resolveTaskParkedColumnsSync(this.store, task.id);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-21:00 (fleet):
|
||||
PREFER the lanes the emitter resolved. The sync resolver below is inert in production — it
|
||||
answers with the DEFAULT workflow under PostgreSQL — so before this it made every lane guard in
|
||||
this listener behave exactly as the literal it replaced.
|
||||
|
||||
Resolving here instead was not an option: this listener's synchronous prologue is load-bearing
|
||||
(`snapshotManager.invalidate` is asserted to run before the emit returns), and an await ahead of
|
||||
it fails `scheduler-auto-claim-invalidation.test.ts`. Reading the answer off the payload keeps
|
||||
the prologue synchronous AND makes the guard correct.
|
||||
|
||||
The sync path stays as the fallback for emit sites that cannot resolve. It is no better than it
|
||||
was, but it is no worse, and it is now the exception rather than the rule.
|
||||
*/
|
||||
const parked = mergeParkedColumns(resolveTaskParkedColumnsSync(this.store, task.id), lanes);
|
||||
if (from === parked.hold || to === parked.hold) {
|
||||
this.options.snapshotManager?.invalidate(`task:moved:${from}->${to}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user