From c9516dbd09185fd15e660603a3be2051db684102 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 02:28:01 -0700 Subject: [PATCH] =?UTF-8?q?fix(engine):=20resolve=20archiveStaleDoneTasks?= =?UTF-8?q?=20lane=20guards=20by=20role=20(fleet:=20self-healing=2056?= =?UTF-8?q?=E2=86=9251)=20(#3047)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet phase. Claimed **`packages/engine/src/self-healing.ts`** — the largest cluster at **56 of 126** total sites. Verified unclaimed first: no open PR touches the file and no active worktree held a branch on it. ## Census before / after | | total | self-healing.ts | |---|---|---| | before | **126** | **56** | | after | **121** | **51** | `census --strict` exits 0; baseline re-recorded in this commit so the retired allowances cannot be regrown into. ## What converted, and why each role `archiveStaleDoneTasks` asked "has this card finished?" by comparing column ids, so on a renamed board it treated every finished card as live and archived nothing — the sweep was inert on exactly the boards this program exists to support. - **active-dependents scan** and **temp-worktree age gate** → `TERMINAL_ROLES` (complete ∪ archived): both ask "is this card done with, in any sense?" - **staleness filter** → `complete` **alone**: this sweep *archives* finished cards, so an already-archived card is not a candidate. Using the terminal pair here would have made the sweep consider its own output. **Union, not per-task, deliberately.** Over-inclusion is free at these sites because the per-card check still discards, and the union needs no per-task workflow selection — the failure mode `resolveWorkflowIrForTask` has, where a card with no recorded selection silently resolves to the built-in board. Recorded in `docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md`. ## The half-converted state is the interesting part Converting only the first two guards made `archiveStaleDoneTasks` **register as a converted sweep** — the existing ratchet suite grew from **36 to 38 tests** — and it then failed for still carrying `t.column !== "done"`. That is the failure mode worth naming: a partial conversion is worse than none, because the function now *looks* converted (it calls the resolver, it reads as role-aware) while one guard still pins it to the legacy vocabulary. Finishing the function turned it green. I would not have caught it from the diff. ## Verification - `self-healing` suites — **807 pass** (41 files) - `tsc --noEmit` — **0 errors** - `census --strict`, `check:lane-wiring`, `check:fnxc-future-dates`, `check:inert-flag-seams`, `check:sql-column-literals` — all exit 0 ## Flagged, not guessed — the remaining 51 Deliberately left, each for a stated reason rather than an omission: 1. **Move-transition matrices** (~1489–1504): `from`/`to` pairs encoding a legal-transition graph (`in-progress → todo|in-review|done|archived`). These are the *shape* of the lifecycle, not a lane lookup; converting them needs a transition-role model that does not exist yet. Guessing here would encode a wrong graph. 2. **`getLiveTaskColumn` comparisons** (~1398, 5313–5342): compared against a normalizing accessor that manufactures `"archived"` for soft-deleted rows. Those are protocol values, not column ids — converting them changes what the sentinel means. 3. **Sites without store access** in scope (several module-level predicates): need the resolved set threaded in as a parameter, which is a seam change per call site, not a substitution. 4. **`todo` requeue targets** (1927, 6181, 6303, 12060–12064): these pick a destination, so they want the single `intake`/`hold` answer from `resolveLifecycleColumns`, not a set — different arity, and several are inside sweeps whose rebound semantics I would be changing rather than preserving. Each is a real conversion; none is a one-line substitution, and doing them blind is how a guard count drops while behaviour gets worse. Co-authored-by: Claude Opus 5 (1M context) --- packages/engine/src/self-healing.ts | 27 ++++++++++++++++--- .../lib/lifecycle-column-census-baseline.json | 2 +- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 0e7eb497ab..7667fab91b 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -2764,15 +2764,28 @@ export class SelfHealingManager extends SelfHealingGitEvidence { // may read for sibling-spec context (executor prompt). Done/archived // dependents have already consumed the spec and don't block. const tasksWithActiveDependents = new Set(); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:40 (fleet phase — self-healing terminal-lane cluster): + "Has this card finished?" asked by id skips every renamed board, so the sweep silently treats a + finished card as live. Resolved once per sweep via the project union — over-inclusion is free + here because the per-card check below still discards, and the union needs no per-task workflow + selection (docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md). + */ + const dependentTerminalColumns = await resolveProjectColumnsForRoles(this.store, TERMINAL_ROLES); for (const t of tasks) { - if (t.column === "done" || t.column === "archived") continue; + if (dependentTerminalColumns.has(t.column)) continue; for (const depId of t.dependencies ?? []) { tasksWithActiveDependents.add(depId); } } + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:55 (fleet phase): the COMPLETE role, not the terminal + pair — this sweep archives finished cards, so an already-archived one is not a candidate. + */ + const doneColumns = await resolveProjectColumnsForRoles(this.store, ["complete"]); const stale = tasks.filter((t) => { - if (t.column !== "done") return false; + if (!doneColumns.has(t.column)) return false; // Prefer columnMovedAt (when the task entered done); fall back to updatedAt // for legacy tasks that lack the field. const ts = t.columnMovedAt || t.updatedAt; @@ -7206,8 +7219,16 @@ export class SelfHealingManager extends SelfHealingGitEvidence { let detected = 0; const seen = new Set(); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-10:40 (fleet phase — self-healing terminal-lane cluster): + "Has this card finished?" asked by id skips every renamed board, so the sweep silently treats a + finished card as live. Resolved once per sweep via the project union — over-inclusion is free + here because the per-card check below still discards, and the union needs no per-task workflow + selection (docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md). + */ + const sweepTerminalColumns = await resolveProjectColumnsForRoles(this.store, TERMINAL_ROLES); for (const task of tasks) { - if (task.column === "done" || task.column === "archived") continue; + if (sweepTerminalColumns.has(task.column)) continue; if (task.paused === true || task.userPaused === true) continue; if (executingIds.has(task.id) || executingTaskLock.has(task.id)) continue; if (this.options.isTaskActive?.(task.id) === true) continue; diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index ccf44edba7..e532031c5f 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -1,7 +1,7 @@ { "generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline", "byFile": { - "packages/engine/src/self-healing.ts": 56, + "packages/engine/src/self-healing.ts": 51, "packages/engine/src/scheduler.ts": 12, "packages/engine/src/executor.ts": 7, "packages/engine/src/notification/notification-service.ts": 5,