fix(engine): resolve archiveStaleDoneTasks lane guards by role (fleet: self-healing 56→51) (#3047)

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 02:28:01 -07:00
committed by GitHub
parent eb0ee4ae98
commit c9516dbd09
2 changed files with 25 additions and 4 deletions

View File

@@ -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<string>();
/*
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<string>();
/*
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;

View File

@@ -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,