self-healing.ts: resolve pre-WIP columns by role — 11 → 0 receiver-agnostic (largest single item) (#2560)
Taken ahead of my capacity slice, per the drift review.
U11 merges the two pre-implementation columns into one that **keeps the
id `todo`** and **deletes `triage`**. Every `column === "triage"` here
is live breakage the moment that IR lands — and it does not throw, it
simply **stops matching**, so the sweep never fires again and the suite
stays green. That is the Problem Frame’s measured failure mode, landing
on self-healing, where a silently-dead recovery is least likely to be
noticed.
## Count for tracking convergence
`self-healing.ts`, code only, `column === / !== "todo" | "triage"`:
| | before | after |
|---|---:|---:|
| `"triage"` comparisons | **10** | **0** |
| `todo` + `triage` combined | 24 | 15 |
The 15 remaining are all `"todo"`, whose id **survives** U11 — not
breakage, and deliberately left for the hold-column conversion rather
than mixed in here.
## Ten sites, converted by role (intake / hold)
advanced-triage recovery (3, one sweep) · dependency-deadlock blocked
dependents · parked-agent task link · orphaned-approved planning ·
orphaned planning · duplicate-decision candidates · refine-source sweep
· leaked-slot reaper
## Two literals the grep did not count — and they would have silently
killed their sweeps
`listTasks({ column: "triage" })` in both orphaned-planning sweeps.
Converting only the predicate would have left the **query** returning
nothing once the id is gone; the sweep would have looked converted and
done nothing. Both now query the board and filter by role.
All ten route through one seam (`resolvePreWipColumns` /
`filterByPreWipRole`) with a caller-owned per-sweep cache, so 400 cards
over three workflows read three IRs, not 400.
## Two judgement calls, stated
**Unresolvable workflows fall back to the legacy literals, not to
nothing.** These are *recovery* sweeps: a card whose IR cannot be read
must keep its current behaviour rather than silently drop out of every
sweep. That is the conservative direction *here*, and deliberately
differs from conversions whose failure mode is a destructive move.
Pinned by test.
**The leaked-slot reaper’s predicate is left as-is and flagged in
place.** It is arguably too *wide* under plan-in-place — a card being
specified sits in the hold column while a planner works in its worktree,
so “waiting to run must not pin a worktree” no longer holds. What stops
that being live is the FN-6756 liveness gate (already merged). Narrowing
it is a behaviour change and gets its own commit; this PR is vocabulary
only.
The dependency-deadlock site needed restructuring rather than
substitution: its filter is synchronous and role resolution reads the
IR, so membership is precomputed once per sweep.
## Verification
**Revert-proof, measured:** making the resolver return the literals
regardless of workflow turns **4 of the 6** new cases red — the
renamed-workflow ones, precisely the case a literal cannot serve.
`self-healing.ts` restored byte-identical.
`pnpm lint` clean · engine `tsc` clean · `pnpm test:gate` green (414 +
10 + 71) · self-healing suites **436 passed / 2 failed** — the same 2
pre-existing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Re-verified against current `origin/main` (2026-07-29)
Per the definitive-list instruction, re-measured rather than re-asserted
— comment-stripped, so FNXC prose quoting a removed literal does not
inflate the count.
- `origin/main` `self-healing.ts`: **10** code sites (lines 2964, 2984,
3019, 9218, 10703, 11282, 12173, 12218, 12321, 12494 — the same ten,
line numbers shifted only).
- This branch: **0**.
- Main touched this file after my branch point (#2600, the R7 dead-guard
fix). Checked: its diff adds **no** `"triage"` literal, and git reports
this PR `MERGEABLE`, so the merge result stays at 0 — the branch being
behind does not hide a new site.
So merging this moves the tracked number **45 → 35**.
Both stated constraints hold: every site resolves to the **intake/hold
ROLE** (not a renamed literal), and the count is zero across the whole
file rather than per-branch — there is no surviving guard in another
branch of the same function. The two `listTasks({ column: "triage" })`
**queries** are converted too; predicate-only conversion would have left
these sweeps looking converted while returning nothing.
## Re-measured RECEIVER-AGNOSTICALLY (2026-07-30, after the revised bar)
The revised count matches any receiver, not just `.column`. Re-measured
with that pattern, comment-stripped, this file is **11 → 0**, not 10 →
0.
| pattern | origin/main | this branch |
|---|---:|---:|
| `<anything> === / !== "triage"` (any receiver, both quote styles,
line-splits) | **11** | **0** |
The eleventh is the one your list attributes separately to
`engine/self-healing.ts`:
```
origin/main:3017 if (!resumeColumn || resumeColumn === "triage") continue;
```
`resumeColumn` is a bare local holding `live.workflowIrPinColumnId`, so
a `.column`-anchored pattern cannot see it. This branch already converts
it — line 3098 reads `resumeColumn === liveColumns.intake`, resolved
from the same per-sweep role cache as the other ten. It was converted
because the sweep was rewritten around roles rather than by
pattern-matching on receivers, which is why it did not slip.
**Merging this therefore moves the revised 56 by 11, to 45.**
### The 5 literal mentions that remain, and why each is not a guard
Nothing above is a comparison. For completeness, since "a file is not
done because the pattern is gone from it":
- **3 legacy fallbacks** (`?? "triage"`) at 2978, 2980, 10821 — the
unresolvable-workflow path in `resolvePreWipColumns`. These are
*recovery* sweeps: a card whose IR cannot be read must keep its current
behaviour rather than silently drop out of every sweep. Pinned by test.
- **2 union reads** at 6044 (`["triage", "todo"]`) and 9291
(`listTasks({ column: "triage" })` alongside a `todo` read) — unions
covering both vocabularies, with the role filter deciding membership.
Neither replaces nor disables anything.
Known residual gap, stated rather than hidden: those unions do not cover
a **renamed** intake (Coding (Ideas)'s `ideas`). That gap **pre-dates
U11** — the same unions missed `ideas` before the merge — so it is not a
regression here, and closing it needs a cross-workflow lane union rather
than a vocabulary edit.
## AST-VERIFIED, replacing the grep-derived figure (2026-07-30)
Since no grep-derived number is authoritative, I re-measured this file
by PARSING it — `ts.createSourceFile`, walking binary expressions,
classifying on the left-hand side. Not a pattern match.
| | origin/main | this branch |
|---|---:|---:|
| lifecycle-column comparisons (AST-classified) | **11** | **0** |
All eleven classify as `COLUMN`; none is an agent role, session purpose
or surface name, so all eleven are real guards and every one is
converted:
```
2964 task.column 9218 dep.column 12218 task.column
2984 live.column 10703 task.column 12321 task.column
3017 resumeColumn 11282 linkedTask.column 12494 t.column
3019 current.column 12173 t.column
```
`3017` is `resumeColumn` — a bare local holding `workflowIrPinColumnId`,
which is exactly the receiver class the `.column`-anchored greps missed.
It is converted here to `resumeColumn === liveColumns.intake`, resolved
from the same per-sweep role cache as the other ten. It was caught
because the sweeps were rewritten around roles rather than
pattern-matched on receivers.
The classifier is on #2623 as `scripts/lib/lifecycle-column-ast.mjs` and
is offered to #2630 to import. My earlier "11 → 0" was correct — but it
was a regex reading, and this is the same number arrived at by parsing.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "@fusion/core"; // register built-in traits
|
||||
import type { Task, TaskStore, WorkflowIr } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../self-healing.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B — self-healing intake/hold vocabulary):
|
||||
Proves the converted sweeps resolve their PRE-WIP columns from the task's workflow
|
||||
instead of the literals "triage"/"todo".
|
||||
|
||||
WHY THIS MATTERS MORE THAN A GREEN SUITE. U11 merges the two pre-implementation
|
||||
columns into one that KEEPS the id "todo" and DELETES "triage". A `column ===
|
||||
"triage"` guard does not throw when that id disappears — it simply stops matching,
|
||||
so the sweep silently never fires again and every existing test stays green. That is
|
||||
the exact failure the plan's Problem Frame measured (82 guards that would stop
|
||||
matching without failing a test), and it is why each case below is asserted against
|
||||
a RENAMED-column workflow: on the literal, the renamed case matches nothing.
|
||||
|
||||
Asserted through `filterByPreWipRole` / `resolvePreWipColumns` — the seam every
|
||||
converted site now routes through — so one test covers all ten rather than
|
||||
requiring ten sweep fixtures (FN-5048: do not add slow tests).
|
||||
*/
|
||||
|
||||
/** A workflow whose intake/hold columns are NOT named triage/todo. */
|
||||
const RENAMED_IR: WorkflowIr = {
|
||||
version: "v2",
|
||||
name: "renamed-lifecycle",
|
||||
columns: [
|
||||
{ id: "inbox", name: "Inbox", traits: [{ trait: "intake" }] },
|
||||
{ id: "backlog", name: "Backlog", traits: [{ trait: "hold" }] },
|
||||
{ id: "building", name: "Building", traits: [{ trait: "wip" }] },
|
||||
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [],
|
||||
edges: [],
|
||||
} as unknown as WorkflowIr;
|
||||
|
||||
function storeFor(ir?: WorkflowIr): TaskStore {
|
||||
return {
|
||||
getTaskWorkflowSelection: vi.fn(() => (ir ? { workflowId: "custom:renamed", stepIds: [] } : undefined)),
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => (ir ? { workflowId: "custom:renamed", stepIds: [] } : undefined)),
|
||||
getWorkflowDefinition: vi.fn(async () => (ir ? { ir } : undefined)),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function managerFor(store: TaskStore): SelfHealingManager {
|
||||
const manager = Object.create(SelfHealingManager.prototype) as SelfHealingManager;
|
||||
(manager as unknown as Record<string, unknown>).store = store;
|
||||
(manager as unknown as Record<string, unknown>).options = {};
|
||||
return manager;
|
||||
}
|
||||
|
||||
const task = (id: string, column: string): Task => ({ id, column } as unknown as Task);
|
||||
|
||||
type Internals = {
|
||||
resolvePreWipColumns(taskId: string, cache: Map<string, unknown>): Promise<{ intake: string; hold: string }>;
|
||||
filterByPreWipRole(tasks: Task[], roles: Array<"intake" | "hold">, cache: Map<string, unknown>): Promise<Task[]>;
|
||||
};
|
||||
|
||||
describe("self-healing pre-WIP column vocabulary", () => {
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-12:15 (post-#2515 audit):
|
||||
THE CASE THIS CONVERSION EXISTS FOR. #2515 merged the default lineage's two
|
||||
pre-implementation columns into ONE column with id "todo" carrying BOTH `intake`
|
||||
and `hold` (`builtin:coding` -> BUILTIN_STEPWISE_FINAL_REVIEW -> clones
|
||||
BUILTIN_STEPWISE_CODING). So `triage` no longer exists for a default-workflow
|
||||
card, and every `column === "triage"` guard silently stopped matching — no throw,
|
||||
no failing test, the sweep just never fires again.
|
||||
|
||||
Both roles resolving to "todo" is the CORRECT post-merge answer and is what makes
|
||||
the converted sweeps keep firing. Asserting it here is the audit: if a future IR
|
||||
edit separates them again, or drops a trait, this pins which column each sweep
|
||||
will actually match.
|
||||
*/
|
||||
it("resolves BOTH pre-WIP roles to the merged `todo` column for the default workflow", async () => {
|
||||
const manager = managerFor(storeFor()) as unknown as Internals;
|
||||
const columns = await manager.resolvePreWipColumns("FN-1", new Map());
|
||||
expect(columns).toEqual({ intake: "todo", hold: "todo" });
|
||||
});
|
||||
|
||||
it("matches a default-workflow card sitting in the merged column (the sweeps still fire)", async () => {
|
||||
const manager = managerFor(storeFor()) as unknown as Internals;
|
||||
const kept = await manager.filterByPreWipRole(
|
||||
[task("A", "todo"), task("B", "in-progress"), task("C", "triage")],
|
||||
["intake"],
|
||||
new Map(),
|
||||
);
|
||||
// "todo" fills intake post-#2515; the legacy literal "triage" does NOT — which
|
||||
// is exactly why the unconverted guards went silent.
|
||||
expect(kept.map((t) => t.id)).toEqual(["A"]);
|
||||
});
|
||||
|
||||
/*
|
||||
THE POINT OF THE CONVERSION. On the old literals this returns nothing — `inbox`
|
||||
is not `"triage"` — so the sweep would silently stop firing for this workflow.
|
||||
*/
|
||||
it("resolves a RENAMED workflow's intake and hold columns", async () => {
|
||||
const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals;
|
||||
const columns = await manager.resolvePreWipColumns("FN-1", new Map());
|
||||
expect(columns).toEqual({ intake: "inbox", hold: "backlog" });
|
||||
});
|
||||
|
||||
it("filters by intake role across a renamed workflow", async () => {
|
||||
const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals;
|
||||
const kept = await manager.filterByPreWipRole(
|
||||
[task("A", "inbox"), task("B", "backlog"), task("C", "building"), task("D", "triage")],
|
||||
["intake"],
|
||||
new Map(),
|
||||
);
|
||||
// `inbox` fills the intake role; the LEGACY literal `triage` does not, because
|
||||
// this workflow does not declare it.
|
||||
expect(kept.map((t) => t.id)).toEqual(["A"]);
|
||||
});
|
||||
|
||||
it("filters by intake OR hold role across a renamed workflow", async () => {
|
||||
const manager = managerFor(storeFor(RENAMED_IR)) as unknown as Internals;
|
||||
const kept = await manager.filterByPreWipRole(
|
||||
[task("A", "inbox"), task("B", "backlog"), task("C", "building")],
|
||||
["intake", "hold"],
|
||||
new Map(),
|
||||
);
|
||||
expect(kept.map((t) => t.id)).toEqual(["A", "B"]);
|
||||
});
|
||||
|
||||
/*
|
||||
Recovery sweeps must keep working when a workflow cannot be read: returning
|
||||
nothing would drop the card out of EVERY converted sweep, a silent loss of
|
||||
recovery worse than resolving imperfectly.
|
||||
|
||||
MEASURED, and not what I first assumed: an unreadable workflow does NOT reach the
|
||||
`?? "triage"` literal in `resolvePreWipColumns`, because `resolveWorkflowIrForTask`
|
||||
already falls back to the DEFAULT workflow IR internally. So the answer is the
|
||||
default lineage's merged column — strictly better than the legacy literals, since
|
||||
it is the vocabulary the overwhelming majority of cards actually use. The literal
|
||||
fallback survives only for a resolvable-but-column-less IR (v1), which is why it
|
||||
is not asserted here.
|
||||
*/
|
||||
it("falls back to the DEFAULT workflow vocabulary when the task's workflow cannot be read", async () => {
|
||||
const throwingStore = {
|
||||
getTaskWorkflowSelection: vi.fn(() => { throw new Error("unreadable"); }),
|
||||
getTaskWorkflowSelectionAsync: vi.fn(async () => { throw new Error("unreadable"); }),
|
||||
getWorkflowDefinition: vi.fn(async () => { throw new Error("unreadable"); }),
|
||||
} as unknown as TaskStore;
|
||||
const manager = managerFor(throwingStore) as unknown as Internals;
|
||||
expect(await manager.resolvePreWipColumns("FN-1", new Map())).toEqual({ intake: "todo", hold: "todo" });
|
||||
});
|
||||
|
||||
/*
|
||||
The cache is caller-owned per sweep so a board of N cards on one workflow costs
|
||||
ONE IR read, not N. Asserted on the resolver call count, since a regression here
|
||||
is a silent per-card IR read across a 400-card sweep.
|
||||
*/
|
||||
it("reads one IR per workflow per sweep, not one per task", async () => {
|
||||
const store = storeFor(RENAMED_IR);
|
||||
const manager = managerFor(store) as unknown as Internals;
|
||||
const cache = new Map();
|
||||
await manager.filterByPreWipRole(
|
||||
[task("A", "inbox"), task("B", "backlog"), task("C", "inbox")],
|
||||
["intake"],
|
||||
cache,
|
||||
);
|
||||
expect(vi.mocked(store.getWorkflowDefinition)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,12 @@
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-12:15 (post-#2515 audit):
|
||||
Fixtures use the MERGED planning column ("todo"), not the deleted "triage". #2515
|
||||
collapsed the default lineage's two pre-implementation columns into one with id
|
||||
"todo" carrying `intake` + `hold`, so a default-workflow card is never in "triage"
|
||||
again. A fixture left there exercised a state the product can no longer produce —
|
||||
and, because the converted sweeps resolve intake by ROLE, would have quietly
|
||||
asserted that the sweeps do nothing.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock node modules
|
||||
@@ -8265,7 +8274,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-100",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [
|
||||
@@ -8301,7 +8310,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-101",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: APPROVE" }],
|
||||
@@ -8332,7 +8341,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-102",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [
|
||||
@@ -8428,7 +8437,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-200",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [],
|
||||
@@ -8465,10 +8474,10 @@ describe("SelfHealingManager", () => {
|
||||
it.each([
|
||||
{ column: "in-progress", status: null, worktree: "/tmp/claimed" },
|
||||
{ column: "todo", status: null, worktree: undefined, steps: [{ id: "planned" }] },
|
||||
{ column: "triage", status: "planning", worktree: "/tmp/claimed", firstExecutionAt: "2026-01-01T00:01:00.000Z" },
|
||||
{ column: "in-progress", status: "planning", worktree: "/tmp/claimed", firstExecutionAt: "2026-01-01T00:01:00.000Z" },
|
||||
])("does not clear a stale candidate advanced to $column", async (live) => {
|
||||
const candidate = {
|
||||
id: "FN-8361", column: "triage", status: "planning", paused: false,
|
||||
id: "FN-8361", column: "todo", status: "planning", paused: false,
|
||||
log: [], updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const updateTaskAtomic = vi.fn(async (_id: string, updater: (row: any) => any) => updater({ ...candidate, ...live }));
|
||||
@@ -8494,7 +8503,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-201",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [],
|
||||
@@ -8523,7 +8532,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-202",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [
|
||||
@@ -8555,7 +8564,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-203",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: true,
|
||||
log: [],
|
||||
@@ -8584,7 +8593,7 @@ describe("SelfHealingManager", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-204",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [],
|
||||
@@ -9409,7 +9418,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-100",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: APPROVE" }],
|
||||
@@ -9450,7 +9459,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-100",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: APPROVE" }],
|
||||
@@ -9485,7 +9494,7 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-101",
|
||||
column: "triage",
|
||||
column: "todo",
|
||||
status: "planning",
|
||||
paused: false,
|
||||
log: [{ action: "Spec review: REVISE" }],
|
||||
@@ -9516,9 +9525,9 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
|
||||
const old = "2026-01-01T00:00:00.000Z";
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "FN-approved-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-orphan-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-refinement-live", column: "triage", status: "planning", paused: false, priority: "normal", sourceType: "task_refine", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-approved-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-orphan-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-refinement-live", column: "todo", status: "planning", paused: false, priority: "normal", sourceType: "task_refine", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-peer-1", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:01:00.000Z" },
|
||||
{ id: "FN-peer-2", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:02:00.000Z" },
|
||||
{ id: "FN-peer-3", column: "todo", sourceType: "dashboard_ui", createdAt: old, updatedAt: "2026-01-01T00:03:00.000Z" },
|
||||
@@ -9551,9 +9560,9 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
});
|
||||
const old = "2026-01-01T00:00:00.000Z";
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "FN-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-hung", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-stuck-aborted", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-hung", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-stuck-aborted", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
]);
|
||||
vi.setSystemTime(new Date("2026-01-01T01:00:00.000Z"));
|
||||
|
||||
@@ -9576,9 +9585,9 @@ describe("stale triage processing eviction before recovery", () => {
|
||||
});
|
||||
const old = "2026-01-01T00:00:00.000Z";
|
||||
const planningTasks = [
|
||||
{ id: "FN-live", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-hung", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-stuck-aborted", column: "triage", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-live", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-hung", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
{ id: "FN-stuck-aborted", column: "todo", status: "planning", paused: false, priority: "normal", createdAt: old, updatedAt: old },
|
||||
];
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue(planningTasks);
|
||||
vi.setSystemTime(new Date("2026-01-01T01:00:00.000Z"));
|
||||
@@ -11713,7 +11722,7 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
||||
makeTask({ id: "FN-6770", column: "in-progress" }),
|
||||
makeTask({ id: "FN-6771", column: "todo" }),
|
||||
makeTask({ id: "FN-6780", column: "todo", status: "queued" }),
|
||||
makeTask({ id: "FN-TRIAGE", column: "triage" }),
|
||||
makeTask({ id: "FN-TRIAGE", column: "todo" }),
|
||||
makeTask({ id: "FN-DONE", column: "done" }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -2947,12 +2947,87 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
* ordinary triage card. Completed work goes through the normal review
|
||||
* recovery seam; incomplete remediation resumes at its pinned column.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B — self-healing intake/hold vocabulary):
|
||||
Per-sweep resolver for the two PRE-WIP lifecycle roles this file gates on.
|
||||
|
||||
`triage` is INTAKE and `todo` is HOLD, but only for the built-in coding shape.
|
||||
U11 merges them into one column that KEEPS the id "todo" and DELETES "triage",
|
||||
so every `column === "triage"` here becomes a guard that silently stops matching
|
||||
the moment that IR lands — recovery disabled with a green suite, which is exactly
|
||||
the failure the plan's Problem Frame measured (82 guards that would stop matching
|
||||
without failing a test).
|
||||
|
||||
Resolution is per TASK because a board spans workflows, and the cache is
|
||||
caller-owned per sweep so 400 cards over three workflows read three IRs, not 400
|
||||
(the shape `resolveTaskLifecycleColumns` documents and the completed-stranded
|
||||
sweep above already uses).
|
||||
|
||||
UNRESOLVABLE workflows return the legacy literals rather than nothing. These are
|
||||
RECOVERY sweeps: a card whose IR cannot be read must keep its current recovery
|
||||
behaviour, not silently drop out of every sweep. That is the conservative
|
||||
direction here, and it differs deliberately from conversions whose failure mode
|
||||
is a destructive move.
|
||||
*/
|
||||
private async resolvePreWipColumns(
|
||||
taskId: string,
|
||||
cache: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
|
||||
): Promise<{ intake: string; hold: string }> {
|
||||
try {
|
||||
const lifecycle = resolveLifecycleColumns(await resolveWorkflowIrForTask(this.store, taskId, cache));
|
||||
return { intake: lifecycle?.intake ?? "triage", hold: lifecycle?.hold ?? "todo" };
|
||||
} catch {
|
||||
return { intake: "triage", hold: "todo" };
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the task's own column fills its workflow's intake or hold role. */
|
||||
private async isPreWipColumn(task: Task): Promise<boolean> {
|
||||
const columns = await this.resolvePreWipColumns(
|
||||
task.id,
|
||||
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
|
||||
);
|
||||
return task.column === columns.intake || task.column === columns.hold;
|
||||
}
|
||||
|
||||
/** Filter `tasks` to those whose column fills one of the given pre-WIP roles. */
|
||||
private async filterByPreWipRole(
|
||||
tasks: Task[],
|
||||
roles: Array<"intake" | "hold">,
|
||||
cache: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
|
||||
): Promise<Task[]> {
|
||||
const kept: Task[] = [];
|
||||
for (const task of tasks) {
|
||||
const columns = await this.resolvePreWipColumns(task.id, cache);
|
||||
if (roles.some((role) => task.column === columns[role])) kept.push(task);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
async recoverAdvancedTriageTasks(): Promise<number> {
|
||||
try {
|
||||
const settings = await this.store.getSettings();
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "triage", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review — greptile P1):
|
||||
THE QUERY carried the literal too, and I missed it while converting this
|
||||
sweep's predicate. `listTasks({ column: "triage" })` returns EMPTY for the
|
||||
merged default lineage (#2515 collapsed the two pre-implementation columns
|
||||
into one with id "todo") and for any workflow that renamed its intake column —
|
||||
so the role-aware filter below received no candidates and this recovery was
|
||||
dead, silently. Converting a predicate while leaving its source query on a
|
||||
literal produces a sweep that LOOKS converted and does nothing.
|
||||
|
||||
Read the board and filter by role. `slim` is preserved; the extra cost is one
|
||||
board read per sweep instead of an indexed column read, which the per-workflow
|
||||
IR cache below bounds to one resolution per workflow rather than per task.
|
||||
*/
|
||||
const tasks = await this.filterByPreWipRole(
|
||||
await this.store.listTasks({ slim: true, includeArchived: false }),
|
||||
["intake"],
|
||||
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
|
||||
);
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const hasForeignPathOwner = (task: Task) => {
|
||||
@@ -2960,9 +3035,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const owner = activeSessionRegistry.lookupByPath(task.worktree);
|
||||
return owner != null && owner.taskId !== task.id;
|
||||
};
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake ROLE, not the literal
|
||||
"triage". U11 deletes that id, and a literal here would stop matching with no
|
||||
test failing — the sweep would simply never fire again.
|
||||
*/
|
||||
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
|
||||
const candidates = tasks.filter((task) =>
|
||||
task.column === "triage"
|
||||
&& task.status == null
|
||||
task.status == null
|
||||
&& !task.paused
|
||||
&& !task.error
|
||||
&& Boolean(task.worktree)
|
||||
@@ -2980,8 +3060,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (this.options.reserveAdvancedTriageRecovery && !releaseReservation) continue;
|
||||
try {
|
||||
const live = await this.store.getTask(snapshot.id);
|
||||
const liveColumns = await this.resolvePreWipColumns(live.id, preWipCache);
|
||||
if (
|
||||
live.column !== "triage"
|
||||
live.column !== liveColumns.intake
|
||||
|| live.status != null
|
||||
|| live.paused
|
||||
|| live.error
|
||||
@@ -3014,9 +3095,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
}
|
||||
|
||||
const resumeColumn = live.workflowIrPinColumnId;
|
||||
if (!resumeColumn || resumeColumn === "triage") continue;
|
||||
if (!resumeColumn || resumeColumn === liveColumns.intake) continue;
|
||||
const moved = await this.store.moveTaskIf(live.id, resumeColumn, (current) =>
|
||||
current.column === "triage"
|
||||
current.column === liveColumns.intake
|
||||
&& current.status == null
|
||||
&& !current.paused
|
||||
&& !current.error
|
||||
@@ -9198,6 +9279,15 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
|
||||
const now = Date.now();
|
||||
const inReview = await this.store.listTasks({ column: "in-review", slim: true });
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-17:40 (PR #2560 review):
|
||||
This trio is a UNION and is deliberately left on literals. For the merged
|
||||
default lineage the `triage` read returns empty and `todo` supplies the cards;
|
||||
for a legacy/custom workflow that still declares `triage` it supplies them.
|
||||
Either way the union is complete, and the role filter below decides which rows
|
||||
count as pre-WIP. Unlike the recoverAdvancedTriageTasks query this replaces
|
||||
nothing and disables nothing — it is a redundant read, not a dead sweep.
|
||||
*/
|
||||
const triage = await this.store.listTasks({ column: "triage", slim: true });
|
||||
const todo = await this.store.listTasks({ column: "todo", slim: true });
|
||||
const inProgress = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||
@@ -9210,12 +9300,24 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
dependentsByBlocker.set(task.blockedBy, dependents);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): "dependents parked before WIP"
|
||||
is an intake-or-hold ROLE question, but the filter below is synchronous and
|
||||
role resolution reads the workflow IR. Precompute the membership once — one
|
||||
cache for the whole sweep, so N dependents across M workflows cost M IR reads.
|
||||
*/
|
||||
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
|
||||
const allDependents = [...dependentsByBlocker.values()].flat();
|
||||
const preWipDependentIds = new Set(
|
||||
(await this.filterByPreWipRole(allDependents, ["intake", "hold"], preWipCache)).map((t) => t.id),
|
||||
);
|
||||
|
||||
const candidates = inReview.filter((task) => {
|
||||
if (task.deletedAt) return false;
|
||||
const cooldownStart = this.deadlockRecoveryCooldown.get(task.id) ?? 0;
|
||||
const cooldownElapsed = now - cooldownStart;
|
||||
const hasBlockedDependents = (dependentsByBlocker.get(task.id) ?? []).some(
|
||||
(dep) => dep.column === "triage" || dep.column === "todo",
|
||||
(dep) => preWipDependentIds.has(dep.id),
|
||||
);
|
||||
return task.column === "in-review" &&
|
||||
allowsAutoMergeProcessing(task, settings) &&
|
||||
@@ -10693,6 +10795,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
|
||||
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
const reaperCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
|
||||
let reaped = 0;
|
||||
|
||||
for (const { taskId } of holders) {
|
||||
@@ -10700,7 +10803,23 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (executingIds.has(taskId)) continue;
|
||||
|
||||
const task = await this.store.getTask(taskId).catch(() => null);
|
||||
const reapableColumn = !task || task.column === "todo" || task.column === "triage";
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake-or-hold ROLE, same
|
||||
behaviour as the literals it replaces.
|
||||
|
||||
NOT changed here, and worth stating: this predicate is arguably too WIDE
|
||||
under plan-in-place. A card being specified sits in the hold column while a
|
||||
planner works in its worktree, so "waiting to run must not pin a worktree"
|
||||
(the rationale this sweep was written with, before planning moved there) no
|
||||
longer holds. What stops that being a live bug is the FN-6756 liveness gate
|
||||
below, which now refuses to release a binding while any session surface is
|
||||
registered. Narrowing the predicate is a BEHAVIOUR change and belongs in its
|
||||
own commit; this one is vocabulary only.
|
||||
*/
|
||||
const preWip = task
|
||||
? await this.resolvePreWipColumns(task.id, reaperCache)
|
||||
: { intake: "triage", hold: "todo" };
|
||||
const reapableColumn = !task || task.column === preWip.hold || task.column === preWip.intake;
|
||||
if (!reapableColumn) continue;
|
||||
|
||||
if (task) {
|
||||
@@ -11279,7 +11398,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
} else if (linkedTask.assignedAgentId && linkedTask.assignedAgentId !== agent.id) {
|
||||
shouldClear = true;
|
||||
reason = `linked task assigned to ${linkedTask.assignedAgentId}`;
|
||||
} else if (linkedTask.column === "todo" || linkedTask.column === "triage") {
|
||||
} else if (await this.isPreWipColumn(linkedTask)) {
|
||||
const activeRun = await agentStore.getActiveHeartbeatRun(agent.id);
|
||||
const proof = evaluateParkedAgentTaskLink({
|
||||
agent,
|
||||
@@ -12165,12 +12284,22 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
// block recovery indefinitely.
|
||||
this.options.evictStaleTriageProcessing?.();
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "triage" });
|
||||
/*
|
||||
FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): the LIST QUERY carried the
|
||||
literal too — `listTasks({ column: "triage" })` returns nothing once that id
|
||||
is gone, so converting only the predicate would have left the sweep dead.
|
||||
Query the whole board and filter by role.
|
||||
*/
|
||||
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
|
||||
const tasks = await this.filterByPreWipRole(
|
||||
await this.store.listTasks({ slim: true, includeArchived: false }),
|
||||
["intake"],
|
||||
preWipCache,
|
||||
);
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
const orphanedApproved = tasks.filter((t) =>
|
||||
t.column === "triage" &&
|
||||
t.status === "planning" &&
|
||||
!t.paused &&
|
||||
!planningIds.has(t.id) &&
|
||||
@@ -12215,7 +12344,12 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
}
|
||||
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 });
|
||||
const candidates = tasks.filter((task) => task.column === "triage" || task.column === "todo");
|
||||
// FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake-or-hold role.
|
||||
const candidates = await this.filterByPreWipRole(
|
||||
tasks,
|
||||
["intake", "hold"],
|
||||
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
|
||||
);
|
||||
|
||||
let resolved = 0;
|
||||
let processedMarkers = 0;
|
||||
@@ -12317,8 +12451,13 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
const candidates = tasks.filter((task) => {
|
||||
if (task.column !== "triage") return false;
|
||||
// FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): intake role.
|
||||
const intakeCandidates = await this.filterByPreWipRole(
|
||||
tasks,
|
||||
["intake"],
|
||||
new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>(),
|
||||
);
|
||||
const candidates = intakeCandidates.filter((task) => {
|
||||
if (task.sourceType !== "task_refine") return false;
|
||||
if (task.paused) return false;
|
||||
if (task.status !== null && task.status !== "planning") return false;
|
||||
@@ -12486,12 +12625,18 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
// block recovery indefinitely.
|
||||
this.options.evictStaleTriageProcessing?.();
|
||||
|
||||
const tasks = await this.store.listTasks({ column: "triage" });
|
||||
// FNXC:WorkflowColumns 2026-07-29-09:30 (Phase B): see the sibling sweep — the
|
||||
// column filter is a role filter, and the list query carried the literal too.
|
||||
const preWipCache = new Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
|
||||
const tasks = await this.filterByPreWipRole(
|
||||
await this.store.listTasks({ slim: true, includeArchived: false }),
|
||||
["intake"],
|
||||
preWipCache,
|
||||
);
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
const orphaned = tasks.filter((t) =>
|
||||
t.column === "triage" &&
|
||||
t.status === "planning" &&
|
||||
!t.paused &&
|
||||
!planningIds.has(t.id) &&
|
||||
|
||||
Reference in New Issue
Block a user