From 998d75da3b3d4afdf3073d6f69fe78736a78fb86 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 03:07:19 -0700 Subject: [PATCH] refactor(core): resolve the hand-off archive guard by role (fleet) (#3054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Census | | column guards | |---|---| | before (this branch) | **122** | | after | **118** | Baseline re-recorded in the same commit, as the ratchet requires. (Four of the delta land with #3052; this PR carries `moves.ts`.) ## What changed `handoffToReviewImpl` refuses a hand-off from an archived card. Against the literal `archived`, a board whose archive lane is renamed **never matched** — so an archived card could be handed to review, and the invariant `HandoffInvariantViolationError` exists to protect was silently unenforced. The IR is resolved at the guard rather than 28 lines below where `handoffTarget` already reads it; the later read now **reuses** it instead of resolving twice. The hoist is safe because this function has already awaited `readTaskRowAsync` above — no new tick boundary. That's the specific hazard blocking the scheduler cluster, so I checked it here rather than assuming. Absent or trait-free IR keeps the legacy id: unconverted boards are byte-identical. ## Fleet intelligence: the backlog is now essentially fully triaged I worked down the census top-files list and verified each before writing. **Every remaining cluster is claimed, fallback-by-design, or documented-blocked:** | cluster | guards | status | |---|---|---| | `self-healing.ts` | 51 | **claimed** — checked out in another worktree (`convert/self-healing-lane-cluster-u7`) | | `scheduler.ts` | 12 | **blocked**, documented at line 907 — `task:moved` prologue is synchronous; hoisting reorders this listener against every other subscriber | | `notification-service.ts` | 5 | **blocked**, documented — needs the wedge-episode contract serialised first; the second site needs gate-placement judgement in `handleTaskUpdated` | | `executor.ts` | 4 | **claimed** (`fleet/executor-lifecycle-roles`) | | `restart-recovery-coordinator.ts` | 4 | **trait-fallback arms** — the census counts these as already converted | | `taskRevert.ts` | 2 | **blocked**, documented — would classify a *neighbour* task with the modal's own flags (the wrong-row shape, worse than the literal) | | `project-store-ops.ts` | 2 | **blocked**, documented — the dead SQLite twin; its first statement throws under PostgreSQL | | `github-tracking-state.ts`, `planner-overseer.ts`, `async-mission-store-queries.ts`, `register-task-workflow-routes.ts` | 2 each | **trait-fallback arms** | | `auto-merge-finalization.ts` | 2 | one is the `catch`-block degraded fallback; the other is a reason string | So the mechanical conversions are done. What's left needs either a design change (scheduler's event payload, notification's episode contract) or per-row lane data that doesn't exist at the call site yet (`taskRevert`). **That's the useful signal for the fleet**: further census reduction isn't a matter of more conversion passes. Forcing these would produce exactly the "conversions that break the code and improve the number" the learnings doc is named for. Co-authored-by: Claude Opus 5 (1M context) --- packages/core/src/task-store/moves.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/core/src/task-store/moves.ts b/packages/core/src/task-store/moves.ts index 619d1603f0..65c43009fb 100644 --- a/packages/core/src/task-store/moves.ts +++ b/packages/core/src/task-store/moves.ts @@ -307,7 +307,26 @@ export async function handoffToReviewImpl(store: TaskStore, taskId: string, opts throw error; } - if (task.column === "archived" || task.deletedAt != null) { + /* + FNXC:WorkflowResolvedColumns 2026-07-31-12:30 (fleet — moves.ts cluster): + THE ARCHIVE GUARD IS A ROLE QUESTION, resolved from the task's own workflow. + + This refuses a hand-off from an archived card. Against the literal `archived`, a board whose + archive lane is renamed never matched, so an archived card could be handed to review — the + invariant this error exists to protect, silently unenforced. + + The IR is resolved here rather than 28 lines down where `handoffTarget` already reads it, and + that hoist is safe: this function has already awaited `readTaskRowAsync` above, so no new tick + boundary is introduced. The later read reuses this one. + + Absent or trait-free IR keeps the legacy id, so an unconverted board is byte-identical. + */ + const handoffIr = await resolveWorkflowIrForTask(store, taskId).catch(() => undefined); + const handoffArchivedLanes = handoffIr ? new Set(columnsWithFlag(handoffIr, "archived")) : undefined; + const taskIsArchived = handoffArchivedLanes && handoffArchivedLanes.size > 0 + ? handoffArchivedLanes.has(task.column) + : task.column === "archived"; + if (taskIsArchived || task.deletedAt != null) { throw new HandoffInvariantViolationError( taskId, task.column, @@ -335,7 +354,7 @@ export async function handoffToReviewImpl(store: TaskStore, taskId: string, opts is the lane a completion handoff belongs in; a `humanReview`-only lane is somewhere a card can BE in review, not somewhere the engine should PUT it. */ - const handoffIr = await resolveWorkflowIrForTask(store, taskId).catch(() => undefined); + /* Reuses the IR hoisted for the archive guard above rather than resolving twice. */ const handoffTarget = (handoffIr ? resolveLifecycleColumns(handoffIr)?.review : undefined) ?? "in-review"; return store.moveTaskInternal(