feat(core): untraited-project lane opt-in — and main was red on the FNXC gate (#2949)

Two things, and the second is why the first does not ship alone.

## The opt-in

`resolveProjectColumnsForRoles` gains `untraitedProject:
"declared-columns"`. When **no** workflow in the project expresses
**any** lifecycle trait, every declared column id joins the answer.

This is the three-state rule at **project** scope — the last item on the
deferred list, recorded at three self-healing call sites (#2869, #2876).
A board that renames its lanes and declares no traits contributes
nothing today, so its cards are **absent from every role-keyed query**,
and the correct per-card fallback downstream never runs for them. A
fallback cannot rescue a card the query never returned.

**Not "no workflow declares this role."** A project that expresses
traits and has no review lane has *answered*; widening there would
invent lanes it deliberately lacks. Mutation-verified both directions —
widening unconditionally fails 1 of 12, making the option a no-op fails
1 of 12.

**Opt-in, not default**, because the safe direction differs per caller —
the finding in `project-union-versus-per-task-lanes.md`:

| caller | over-inclusion costs |
|---|---|
| sweep | nothing — the per-card check discards the extra rows |
| aggregator | an inflated number an operator reads (#2864, #2866) |
| action site | a card routed or notified under a vocabulary that is not
its own (#2852, #2891) |

Making it the default moves all three at once, in the one direction two
of them must not. Verified byte-identical without the option, so this
lands with **no caller changes** and each site adopts it on its own
reasoning.

## Main was red, and my own gate caught me first

I dated the new comments `2026-07-31` while today is `2026-07-30` —
**the exact defect `check-fnxc-future-dates` exists to prevent,
committed while writing the feature.** The gate I added yesterday failed
my own commit.

Correcting mine surfaced that the merged sentinel batch, #2947, and
three engine test files carried future-dated stamps too, so **the gate
was failing on `main` for everyone**, not just here.

All corrected to real dates rather than raising the ceiling. The stamps
were simply wrong, and a baseline bump would have recorded the error as
permitted — which is the failure mode that ratchet exists to prevent.

Core and engine `tsc` clean, `pnpm lint` clean, census `--strict` 0,
FNXC gate 0 (469 known, none added), gate green (161/487/13/71).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 20:17:18 -07:00
committed by GitHub
parent b1bd571682
commit f49e487d91
15 changed files with 240 additions and 127 deletions

View File

@@ -151,3 +151,71 @@ describe("resolveProjectColumnsForRoles", () => {
}
});
});
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-20:45:
THE UNTRAITED-PROJECT OPT-IN — the three-state rule at PROJECT scope.
A board that renames its lanes but declares NO lifecycle trait contributes nothing to the union, so its
cards are invisible to every role-keyed query — not misclassified downstream, absent from the result
entirely, which is why a correct per-card fallback cannot rescue them (#2869, #2876).
THE THREE CASES ARE THE WHOLE POINT, and the middle one is what keeps this from being a blunt widening:
- project expresses NO trait anywhere -> it has no vocabulary, so its declared ids are the honest
candidate set;
- project expresses traits but this ROLE is absent -> it has ANSWERED, and inventing lanes would
contradict it;
- opt-in absent -> byte-identical to before, which is what makes this safe to land with no caller
changes at all.
*/
describe("resolveProjectColumnsForRoles: untratedProject opt-in", () => {
const storeWith = (...irs: unknown[]) => ({
listWorkflowDefinitions: async () => irs.map((ir) => ({ ir })),
} as never);
const untraited = {
version: "v2", name: "untraited",
columns: [{ id: "drafting", name: "D", traits: [] }, { id: "checking", name: "C", traits: [] }],
nodes: [], edges: [],
};
const traited = {
version: "v2", name: "traited",
columns: [{ id: "building", name: "B", traits: [{ trait: "wip" }] }],
nodes: [], edges: [],
};
it("widens to every declared column when the project expresses no lifecycle trait at all", async () => {
const lanes = await resolveProjectColumnsForRoles(storeWith(untraited), ["mergeOrchestration"], {
untraitedProject: "declared-columns",
});
/* `checking` is the renamed review lane; without the opt-in it is absent and its cards are unseen. */
expect(lanes.has("checking")).toBe(true);
expect(lanes.has("drafting")).toBe(true);
/* The legacy floor stays, so a board mid-rename is not dropped. */
expect(lanes.has("in-review")).toBe(true);
});
it("does NOT widen when some workflow expresses a trait, even if none declares this role", async () => {
/*
The case that keeps this honest. The project HAS a vocabulary — one board declares `wip` — so a
board with no review lane has answered "no review lane", and admitting its columns would contradict
a statement the project actually made.
*/
const lanes = await resolveProjectColumnsForRoles(storeWith(untraited, traited), ["mergeOrchestration"], {
untraitedProject: "declared-columns",
});
expect(lanes.has("checking")).toBe(false);
expect(lanes.has("drafting")).toBe(false);
expect(lanes.has("in-review")).toBe(true);
});
it("is byte-identical to today's answer without the option", async () => {
/* No caller changes behaviour until it asks — the property that makes this landable on its own. */
const withOpt = await resolveProjectColumnsForRoles(storeWith(untraited), ["mergeOrchestration"]);
expect([...withOpt].sort()).toEqual(["in-review"]);
});
});

View File

@@ -469,7 +469,7 @@ export { findWorkflowEventShapeViolations, isIdsOnlyWorkflowEvent, MAX_ID_VALUE_
export type { WorkflowLifecycleEvent, WorkflowLifecycleEventType, WorkflowLifecycleEventBase, TaskTransitionedEvent, NodeEnteredEvent, NodeCompletedEvent, RunSuspendedEvent, RunResumedEvent, WorkflowEventShapeViolation, ImplementationExit } from "./types/workflow-events.js";
export { columnHasFlag, columnsWithFlag, declaresAnyLifecycleTrait, resolveArchiveTargetForTask, resolveCompleteColumn, resolveLifecycleColumns, resolveMergeOrchestrationColumn, resolveReboundTarget, resolveReboundTargetForTask, resolveReviewColumns, resolveTaskLifecycleColumns, resolveTerminalColumns, resolveWipTargetForTask } from "./workflow-lifecycle-traits.js";
export type { LifecycleColumns } from "./workflow-lifecycle-traits.js";
export { resolveProjectColumnsForRoles, resolveArchivedLanes, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore } from "./project-lane-vocabulary.js";
export { resolveProjectColumnsForRoles, resolveArchivedLanes, REVIEW_ROLES, TERMINAL_ROLES, LEGACY_COLUMN_IDS_BY_ROLE, type ProjectLaneVocabularyStore, type ProjectLaneResolutionOptions } from "./project-lane-vocabulary.js";
export { resolveReviewLevelSteps, applyReviewLevelPreset } from "./review-level-preset.js";
export {
LEGACY_STATUS_ADOPTION,

View File

@@ -35,7 +35,7 @@ workflow calls its column review, which is the flat-set mistake this program has
times.
*/
import { columnsWithFlag } from "./workflow-lifecycle-traits.js";
import { columnsWithFlag, declaresAnyLifecycleTrait } from "./workflow-lifecycle-traits.js";
import { parseWorkflowIr } from "./workflow-ir.js";
import type { TraitFlags } from "./trait-types.js";
@@ -79,11 +79,44 @@ export const LEGACY_COLUMN_IDS_BY_ROLE: Record<string, readonly string[]> = {
* @returns a set safe to iterate as `listTasks({ column })` reads. Never empty: the legacy ids are
* always present, so a caller cannot accidentally query nothing.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-20:30:
THE UNTRAITED-PROJECT OPT-IN — the three-state rule at PROJECT scope, and why it cannot be a default.
This helper seeds the legacy ids and adds whatever workflows DECLARE for the role. A board that renames
its lanes but declares NO lifecycle trait on any column therefore contributes nothing, so its cards are
invisible to every query keyed on a role — the card is not in the result at all, and a correct per-card
fallback downstream never runs for it. Recorded at three self-healing call sites (#2869, #2876).
`untraitedProject: "declared-columns"` widens the answer for exactly that case: when NO workflow in the
project expresses ANY lifecycle trait, every declared column id joins the set. Not "no workflow declares
THIS role" — a board that expresses traits and simply has no review lane has ANSWERED, and widening
there would invent lanes it deliberately does not have.
WHY IT IS OPT-IN AND NOT THE DEFAULT. The safe direction differs by caller, which is the whole finding
of `docs/solutions/workflow-learnings/project-union-versus-per-task-lanes.md`:
- a SWEEP over-includes harmlessly — the per-card check downstream discards the extra rows, and the
cost is a few wasted `listTasks` calls;
- an AGGREGATOR does not — the same widening inflates a number an operator reads (#2864, #2866);
- an ACTION site does not — over-inclusion means a card routed or notified under a vocabulary that is
not its own (#2852, #2891).
Making this the default would silently change all three at once, in the one direction two of them must
not move. So it changes nothing until a caller asks for it, and each caller asks for its own reasons.
*/
export interface ProjectLaneResolutionOptions {
/** Widen to every declared column when the project expresses no lifecycle trait at all. */
untraitedProject?: "declared-columns";
}
export async function resolveProjectColumnsForRoles(
store: ProjectLaneVocabularyStore,
roles: ReadonlyArray<keyof TraitFlags & string>,
options: ProjectLaneResolutionOptions = {},
): Promise<ReadonlySet<string>> {
const columns = new Set<string>();
/* Collected while walking the definitions so the widening needs no second read. */
const declaredColumnIds = new Set<string>();
let anyTraitExpressed = false;
for (const role of roles) {
for (const legacy of LEGACY_COLUMN_IDS_BY_ROLE[role] ?? []) columns.add(legacy);
}
@@ -117,11 +150,26 @@ export async function resolveProjectColumnsForRoles(
for (const role of roles) {
for (const id of columnsWithFlag(ir as never, role)) columns.add(id);
}
if (options.untraitedProject === "declared-columns") {
for (const column of (ir as { columns?: { id?: string }[] }).columns ?? []) {
if (typeof column?.id === "string") declaredColumnIds.add(column.id);
}
if (declaresAnyLifecycleTrait(ir as never)) anyTraitExpressed = true;
}
} catch {
continue;
}
}
/*
Only when the project as a WHOLE expressed nothing. A single traited workflow means the project has a
vocabulary, and a board inside it that declares no lifecycle trait is that board's own omission — not
something to paper over by admitting every column in the project.
*/
if (options.untraitedProject === "declared-columns" && !anyTraitExpressed) {
for (const id of declaredColumnIds) columns.add(id);
}
return columns;
}

View File

@@ -485,7 +485,7 @@ export async function listTaskDocuments(
archivedColumns?: ReadonlySet<string>,): Promise<TaskDocument[]> {
const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns);
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —
@@ -560,7 +560,7 @@ export async function deleteTaskDocument(
return layer.transactionImmediate(async (tx) => {
const state = await getLiveTaskColumn(tx, taskId, layer.projectId, archivedColumns);
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —
@@ -634,7 +634,7 @@ export async function insertArtifactRow(
if (input.taskId) {
const column = await getLiveTaskColumn(tx, input.taskId, layer.projectId, archivedColumns);
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —
@@ -705,7 +705,7 @@ export async function updateArtifactRow(
if (existing.taskId) {
const column = await getLiveTaskColumn(tx, existing.taskId, layer.projectId, archivedColumns);
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —
@@ -777,7 +777,7 @@ export async function getArtifacts(
archivedColumns?: ReadonlySet<string>,): Promise<Artifact[]> {
const column = await getLiveTaskColumn(db, taskId, projectId, archivedColumns);
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —

View File

@@ -63,7 +63,7 @@ export async function addCommentImpl(store: TaskStore, id: string, text: string,
const layer = store.asyncLayer!;
const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —

View File

@@ -657,7 +657,7 @@ export async function updateTaskCommentImpl(store: TaskStore, id: string, commen
const layer = store.asyncLayer!;
const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —
@@ -704,7 +704,7 @@ export async function deleteTaskCommentImpl(store: TaskStore, id: string, commen
const layer = store.asyncLayer!;
const state = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —

View File

@@ -455,7 +455,7 @@ export async function isTaskArchivedAsyncImpl(store: TaskStore, id: string): Pro
const live = await getLiveTaskColumn(layer.db, id, layer.projectId, await resolveArchivedLanes(store));
// getLiveTaskColumn returns "archived" for archived OR soft-deleted rows.
/*
FNXC:LifecycleColumnCensus 2026-07-31-03:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
FNXC:LifecycleColumnCensus 2026-07-30-21:10 DELIBERATE-LITERAL: a SENTINEL, not a board lane.
This compares `getLiveTaskColumn`'s RETURN VALUE. That helper normalizes: it manufactures the string
"archived" for an archived row AND for a soft-deleted one, and returns null for a missing task —

View File

@@ -219,7 +219,7 @@ describe("resume lanes come from the task's own workflow", () => {
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-19:30 (a MISSED PAIR in resumeOrphaned):
FNXC:WorkflowResolvedColumns 2026-07-30-21:30 (a MISSED PAIR in resumeOrphaned):
`listWipLaneTasks()` already resolved the wip lane by role, and the filter beneath it did NOT — it
re-asserted the literal `in-progress` on the rows that read returned. So on a renamed board the read
found the orphans and the filter discarded every one.

View File

@@ -1,5 +1,5 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-31-19:55 (the MISSED PAIR ratchet, generalised past self-healing):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the MISSED PAIR ratchet, generalised past self-healing):
THE DEFECT THIS CATCHES, found in `executor.ts` after the sibling ratchet found five in
`self-healing.ts`: a function resolves its lane by ROLE and then re-asserts a column LITERAL on the

View File

@@ -1,5 +1,5 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-31-11:55 (the missed-pair ratchet):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the missed-pair ratchet):
A sweep whose READ was converted to resolved lanes but whose LOOP-BODY guards still compare column ids
is worse than one converted nowhere. The widened read admits renamed-board cards, and every literal
guard below it then mis-classifies exactly those cards.

View File

@@ -38,20 +38,20 @@ import type { Settings, Task, TaskStore } from "@fusion/core";
import { getTaskHardMergeBlocker, resolveLifecycleColumns } from "@fusion/core";
/*
FNXC:WorkflowResolvedColumns 2026-07-31-04:40:
FNXC:WorkflowResolvedColumns 2026-07-30-21:40:
`classifyForeignOnlyContamination` is a STATIC named import in the sweep, so `vi.spyOn` on the module
object cannot intercept it under ESM — the binding is already resolved. Only the other named exports are
passed through, so the sweeps in this file that use `inspectBranchConflict` are unaffected.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-31-05:45:
FNXC:WorkflowResolvedColumns 2026-07-30-21:40:
The sweep logs through `createLogger("self-healing")`, which writes to console.error. Spying on
console.error does NOT work here — vitest installs its own console interceptor above the spy, so the
line appears in the run output while the spy records nothing (it did, and read as "no warn emitted").
Mocking the logger module captures the call itself, one level below the console.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-31-18:40 (batch fold):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (batch fold):
`isBranchAheadOfBase` is a STATIC named import that shells out to git, so it is mocked rather than spied —
the ESM binding is resolved before a spy could replace it.
*/
@@ -693,7 +693,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(pastBlocker).toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-06:15 (the query-filter class, fifteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, fifteenth sweep):
`recoverMergedReviewTasks` finalizes a task whose merge is CONFIRMED but which never reached the
complete lane. Two literal reads meant that on a renamed board the card sat in review or hold forever
while its commit was already on the base branch — merged work that the board still shows as unfinished.
@@ -754,7 +754,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(resolveTarget).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-09:45 (the query-filter class, twenty-first sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-first sweep):
`recoverStaleMergingStatus` clears a `merging`/`merging-pr` stamp left on a review card with no live
merger behind it. The literal read meant that on a renamed board the stamp was never cleared, so the
card read as mid-merge forever — and that stamp is what the merger AND the dashboard's manual Retry
@@ -801,7 +801,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(updateTask).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-04:35 (the query-filter class, fourteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, fourteenth sweep):
`recoverForeignOnlyContaminatedInReviewTasks` classifies a branch that carries ONLY foreign commits and
clears the contamination park nothing else clears. Two literal reads meant that on a renamed board it
classified nothing and the task stayed parked indefinitely.
@@ -853,7 +853,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(classifyForeignOnlyContamination).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-05:50 (#2891 review P1 — the card the sweep disowned):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2891 review P1 — the card the sweep disowned):
`resolveWorkflowIrForTask` does not fail; it SUBSTITUTES the built-in IR. So a card whose workflow
selection is missing or unreadable came back measured against `in-review`/`in-progress`, and the
per-card verdicts then REJECTED the very card the project-scoped query had just admitted from a renamed
@@ -952,7 +952,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(updateTask).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:50 (the query-filter class, eleventh sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, eleventh sweep):
`clearStaleBlockedBy` is the sweep that unsticks a card still pointing at a blocker that has since
finished. Its BODY was already lane-resolved — per-referenced-task lanes, a shared IR cache, legacy ids
unioned, all of it — and none of that ran, because the three reads above it asked for the literal
@@ -1001,7 +1001,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(updateTask).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-01:35 (the query-filter class, twelfth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twelfth sweep):
`reclaimSelfOwnedBranchConflicts` frees a task whose OWN worktree is holding its OWN branch hostage —
a conflict no other sweep resolves. Three literal reads plus three lane guards in the body, so both
halves convert together: widening the read alone would admit renamed-board cards and then mis-decide
@@ -1076,7 +1076,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(isPhantomExecutorBinding).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:50 (review P1 on #2879 — the hazard the conversion CREATED):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (review P1 on #2879 — the hazard the conversion CREATED):
The three literal reads were disjoint BY CONSTRUCTION: one column each, so a card could not appear
twice. Resolved reads are not. A custom workflow may put more than one queried role flag on the SAME
column — here `hold` beside `wip`, a lane that both parks work and counts as work — and that column is
@@ -1125,7 +1125,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(isPhantomExecutorBinding).toHaveBeenCalledTimes(1);
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:10 (the query-filter class, thirteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirteenth sweep):
`reconcileCompletedTask` releases everything blocked on a task that just completed. Three literal reads
meant that on a renamed board it released NOTHING — every dependent stayed blocked on work that had
already finished. This is the most visible form of the class: the board simply stops moving, with no
@@ -1172,7 +1172,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(updateTask).not.toHaveBeenCalledWith("FN-WAITING", expect.objectContaining({ blockedBy: null }));
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-04:10 (the P1 raised on #2879, same hazard in this sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the P1 raised on #2879, same hazard in this sweep):
Resolved reads can return ONE column for TWO roles, so a dependent lands in two buckets and the release
below runs twice — `updateTask` and `logEntry` both fire twice for one card, and `blockedByCleared`
over-counts. The literal reads could not do this: one column each, disjoint by construction.
@@ -1209,7 +1209,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(clearingWrites).toHaveLength(1);
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-05:05 (#2883 review — "overbroad dependency satisfaction"):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2883 review — "overbroad dependency satisfaction"):
A dependency is satisfied when it reaches a TERMINAL lane or a REVIEW lane, and review here means
`mergeBlocker ∪ humanReview` — NOT merge orchestration. My first version unioned all three review
roles, which counts a merge-orchestration-only column as satisfied and clears `blockedBy` while the
@@ -1262,7 +1262,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(updateTask).toHaveBeenCalledWith("FN-WAITING", expect.objectContaining({ blockedBy: "FN-MIDMERGE" }));
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-06:45 (the query-filter class, sixteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, sixteenth sweep):
A card that reached a terminal lane while still carrying `merging`/`merging-pr` holds the MERGER QUEUE.
Two literal reads meant that on a renamed board the stale status was never cleared, so one finished
card blocked every task queued behind it — the widest blast radius in this series, since the damage is
@@ -1297,7 +1297,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(updateTask).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-07:15 (the query-filter class, seventeenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, seventeenth sweep):
`recoverCompletedTasks` rescues a task whose steps are ALL done but whose session died before the
executor could hand it to review. The literal read meant that on a renamed board it was never found:
finished implementation work sat in the wip lane with no session and nothing to move it on.
@@ -1342,7 +1342,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(recoverCompletedTask).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-07:45 (the query-filter class, eighteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, eighteenth sweep):
`recoverInProgressLimbo` frees a card holding a wip slot with NO worktree, NO branch and no step
started — nothing is running and nothing will. The literal read meant that on a renamed board it was
never found, so the card kept its slot forever and denied that capacity to work that could run.
@@ -1404,7 +1404,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(signal).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-08:15 (the query-filter class, nineteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, nineteenth sweep):
`recoverOrphanedExecutions` takes NO lifecycle action — it emits `task:orphan-detected-no-action` so an
operator can see a wip card with no live session behind it. The literal read meant that on a renamed
board the event was never emitted, so the one signal pointing at an orphaned execution was silently
@@ -1459,7 +1459,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
);
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-08:45 (the query-filter class, twentieth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twentieth sweep):
`reattachOrphanedAssignedExecutions` reattaches a DURABLE AGENT to a task it is still assigned to but
has stopped executing. The literal read meant that on a renamed board the reattach never fired, so the
card sat assigned-but-idle — visibly owned by an agent that had gone quiet, which is worse than
@@ -1513,7 +1513,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(resumeAssignedTaskForAgent).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:25 (the query-filter class, twenty-second sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-second sweep):
A GHOST review card is one parked in review past the stuck timeout with nobody owning its merge lane.
The literal read meant that on a renamed board it was never found: no merger, no session, and no
timeout ever firing against it.
@@ -1567,7 +1567,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(isMergeLaneOwned).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-11:00 (the query-filter class, twenty-third sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-third sweep):
`recoverTransientMergeFailures` refunds the retry budget for a merge that failed for a TRANSIENT reason
and burned all its retries. The literal read meant that on a renamed board the refund never happened,
so a card that failed on a network blip or a provider fault stayed failed permanently — visibly failed
@@ -1616,7 +1616,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(requeueForAutoMerge).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-12:45 (the query-filter class, twenty-fourth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-fourth sweep):
`recoverStaleIncompleteReviewTasks` requeues a review card whose STEPS are not finished — it reached
review on a graph failure, not on completed work. The literal read meant that on a renamed board it was
never requeued: the card sat in review claiming to be done while its own steps said otherwise.
@@ -1670,7 +1670,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(proof).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-13:25 (the query-filter class, twenty-fifth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-fifth sweep):
`recoverMisclassifiedFailures` clears a failure the executor parked for "without calling fn_task_done"
on a task whose steps are ALL actually done — the failure is a misclassification, not real work left
undone. The literal read meant that on a renamed board it was never cleared, so finished work stayed
@@ -1713,7 +1713,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(updateTask).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-14:15 (the query-filter class, twenty-sixth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-sixth sweep):
`recoverBranchMisboundInReviewTasks` detects a review card whose BRANCH TIP is bound to a different
task's work. The literal read meant that on a renamed board the misbinding was never detected, so the
card would merge — or refuse to — against a branch that is not its own.
@@ -1765,7 +1765,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(resolveTarget).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-14:55 (the query-filter class, twenty-seventh sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-seventh sweep):
`recoverMissingWorktreeReviewFailures` requeues a review card failed because its worktree was gone when
the session tried to start. Its per-candidate lane wiring was already in place — and a note at the site
called the literal QUERY above it "unfixable without a project-level lane resolution before the read".
@@ -1839,7 +1839,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(proof).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-NOWT2" }), expect.anything());
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-15:40 (the query-filter class, twenty-eighth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-eighth sweep):
`auditNoCommitsExpectedCandidates` flags a card that finished every step and pushed NO commits — either
a legitimately commit-free task nobody declared as such, or work that silently produced nothing.
@@ -1890,7 +1890,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(flagged).toBe(0);
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-16:10 (the query-filter class, twenty-ninth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-ninth sweep):
`recoverNoProgressNoTaskDoneFailures` requeues a wip card the executor failed for "no fn_task_done"
that made NO step progress and left no git work — nothing to salvage, so requeueing is safe. The
literal read meant that on a renamed board it was never requeued: a card that produced nothing sat
@@ -1937,7 +1937,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(hasRecoverableGitWork).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-16:40 (the query-filter class, thirtieth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirtieth sweep):
`recoverPartialProgressNoTaskDoneFailures` retries a review card failed for "no fn_task_done" that DID
make step progress. Real work exists, so the sweep spends a retry rather than discarding it. The
literal read meant that on a renamed board the retry never fired: partially-completed work was parked
@@ -1988,7 +1988,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(proof).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-17:10 (the query-filter class, thirty-first sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirty-first sweep):
`recoverDoneTaskMergeMetadata` repairs the merge metadata of a card that already reached the COMPLETE
lane — the commit sha an operator sees, and that later reconcilers trust. The literal read meant that
on a renamed board a done card's metadata was never repaired, so a completed task could keep pointing
@@ -2034,7 +2034,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
expect(findLandedTaskCommit).not.toHaveBeenCalled();
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-18:15 (the query-filter class, sweeps thirty-three and -four):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, sweeps thirty-three and -four):
The two WORKSPACE sweeps. A workspace task lands PER-REPO, so its failure modes are its own: a
partial land leaves some repos merged and some not, and a finished one leaves per-repo worktrees on
disk. Both were bounded by literal reads, so on a renamed board neither ran — the partial land never
@@ -2100,7 +2100,7 @@ describe("self-healing sweeps are bounded by a hardcoded column QUERY, not by th
/*
FNXC:WorkflowResolvedColumns 2026-07-31-02:10 (#2867 review — greptile, "hard-blocker wiring remains
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2867 review — greptile, "hard-blocker wiring remains
untested"):
THE WIRING, TESTED AT THE SEAM RATHER THAN THROUGH THE SWEEP.

View File

@@ -10864,7 +10864,7 @@ describe("SelfHealingManager reclaimStaleActiveBranches (FN-4546)", () => {
});
/*
FNXC:WorkflowResolvedColumns 2026-07-31-01:40 (#2879 review — greptile, "multi-role tasks run
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2879 review — greptile, "multi-role tasks run
recovery twice"): THE FIX IS IN `self-healing.ts`; NO TEST HERE, AND THE ABSENCE IS DELIBERATE.
`readBucket` dedupes by id inside ONE role's read, so a custom column carrying two queried traits —

View File

@@ -361,7 +361,7 @@ function mergeAdditionalSkillPaths(...pathGroups: Array<string[] | undefined>):
}
/**
* FNXC:WorkflowSteps 2026-08-08-00:00:
* FNXC:WorkflowSteps 2026-07-30-21:40:
* FN-8461 / GitHub #2388 require workflow skill-load warnings to describe a true
* named-skill delivery failure, not an optional Compound Engineering source being
* absent. Plugin body directories are paired with their parent discovery roots,
@@ -1793,7 +1793,7 @@ export async function resolveTerminalColumnsFor(
store: TaskStore,
taskId: string,
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-09:30 (#2787 review — greptile P2):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (#2787 review — greptile P2):
Optional CALLER-OWNED IR cache, matching the contract on `resolveTaskLifecycleColumns`. Sweeps that
call this once per card on a whole board must read one IR per WORKFLOW, not one per task; callers
resolving a single task pass nothing and are unaffected.
@@ -1801,7 +1801,7 @@ export async function resolveTerminalColumnsFor(
irCache?: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
): Promise<readonly string[]> {
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-12:20 (PR #2568 review — greptile):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2568 review — greptile):
THE UNION IS DELIBERATE, and the `catch` alone was not enough.
`resolveWorkflowIrForTask` does NOT throw when a custom workflow definition is
@@ -1979,7 +1979,7 @@ export class TaskExecutor {
/** Active pre-merge workflow step sessions per task. */
private activeWorkflowStepSessions = new Map<string, AgentSession>();
/**
* FNXC:TaskTiming 2026-08-01-12:00:
* FNXC:TaskTiming 2026-07-30-21:40:
* Only graph-owned Plan Review sessions appear here. Self-healing uses this
* narrow liveness proof so it never finalizes an in-flight planning segment.
*/
@@ -2394,7 +2394,7 @@ export class TaskExecutor {
private async finalizeAlreadyReviewedTask(taskId: string): Promise<"merged" | "blocked" | "missing"> {
const latestTask = await this.store.getTask(taskId);
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:55 (fleet): the board's own review lane. Spelled as the
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): the board's own review lane. Spelled as the
literal, this reported "missing" — a word that reads as "the task is gone" — for a card sitting in
review on a renamed board, and the already-reviewed finalize never ran. */
if (!latestTask || latestTask.column !== (await this.resolveResumeLanes(taskId)).review) {
@@ -2476,7 +2476,7 @@ export class TaskExecutor {
return true;
}
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:45 (fleet: wip-lane liveness family): "still executing"
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: wip-lane liveness family): "still executing"
is the board's WIP lane. With the literal a renamed board deferred EVERY completion handoff — the
card was never in `in-progress`, so this read "no longer active" for a card that was actively
executing, and the handoff was dropped with a log line. */
@@ -2784,7 +2784,7 @@ export class TaskExecutor {
}
/**
* FNXC:TaskTiming 2026-08-01-12:00:
* FNXC:TaskTiming 2026-07-30-21:40:
* A planning segment has one owner: a graph Plan Review session is live only
* while both its session registration and planning ownership marker remain.
* This is intentionally narrower than isTaskActive(), which also covers
@@ -3659,7 +3659,7 @@ export class TaskExecutor {
// Handle unpause of an in-progress task with no active session.
// Approval can be decided while the old session is still unwinding;
// remember that edge instead of losing the only task:updated event.
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:50 (fleet): both checks in this listener ask "is
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): both checks in this listener ask "is
this card still in the wip lane?"; one snapshot for the pair. With the literal neither fired on a
renamed board — an unpaused card with no active session was never resumed. */
const unpauseWipLane = (await this.resolveResumeLanes(task.id)).wip;
@@ -4451,7 +4451,7 @@ export class TaskExecutor {
the task back for remediation, so `in-review` must bounce back exactly like
`in-progress` regardless of the column the completion race left it in.
*/
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:57 (fleet): both lanes from ONE snapshot — the comment
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): both lanes from ONE snapshot — the comment
above says in-review must bounce EXACTLY like in-progress, so resolving them separately is how the
bounce ends up handling one lane and throwing on the other, which is the bug that comment is about. */
const bounceLanes = await this.resolveResumeLanes(taskId);
@@ -4558,7 +4558,7 @@ export class TaskExecutor {
return;
}
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:48 (fleet): the INVERSE of the guard above — this one
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): the INVERSE of the guard above — this one
SKIPS a card that is still executing. Note the direction: with the literal on a renamed board it
never matched, so a rerun could fire on a card mid-execution. A mechanical sweep of every
`!== "in-progress"` would fix the refusals and leave this admission in place. */
@@ -4618,7 +4618,7 @@ export class TaskExecutor {
*/
const terminalColumns = await resolveTerminalColumnsFor(this.store, task.id);
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-12:30 (PR #2568 review — greptile):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2568 review — greptile):
RE-READ AFTER THE AWAIT. The pause and column guards above ran against the `task`
snapshot the caller passed, and this conversion introduced the first `await`
between those guards and the writes below. Another dispatch or an operator action
@@ -4642,7 +4642,7 @@ export class TaskExecutor {
FN-7926: completed work with a persistent `getTaskCompletionBlocker` result must not self-requeue through the execute node. Re-running implementation cannot clear dependency/blockedBy state, so it only feeds FN-7863's generic no-progress backstop and misclassifies good work as `EXECUTION_DISPATCH_LOOP_EXHAUSTED`. Park in a scheduler-skipped todo state, preserve worktree/branch/steps, and reset the FN-7863 signature so the backstop remains reserved for genuinely incomplete no-progress loops.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-17:10 (rebase merge, both sides kept):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (rebase merge, both sides kept):
main (#2644) resolved the literal `todo` into `reboundColumn`; this branch added the
post-await `liveTask` re-read. Taking either side alone loses the other — the
literal comes back, or the stale snapshot does.
@@ -4714,7 +4714,7 @@ export class TaskExecutor {
}
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-20:35 (PR #2703 review — greptile P1):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2703 review — greptile P1):
The review lane arrives from the caller for the reason documented on `isBenignInReviewPauseAbort`: the
synchronous resolver returns the default workflow in PostgreSQL mode, so resolving it here would have
been a conversion that changes the census and not the behaviour.
@@ -4750,7 +4750,7 @@ export class TaskExecutor {
await this.persistTokenUsage(task.id);
/*
FNXC:WorkflowLifecycleColumns 2026-08-02-05:50 (PR #2703 review — greptile P1, and it is the same split
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2703 review — greptile P1, and it is the same split
I have been fixing all day, in code I wrote an hour earlier):
ONE SNAPSHOT. The eligibility check above already resolved this task's lanes
(`nonContinuableLanes`), and this branch resolved them AGAIN. A workflow selection or review-column
@@ -5227,7 +5227,7 @@ export class TaskExecutor {
default lineage.
*/
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-23:10 (the sync resolver never resolved):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (the sync resolver never resolved):
AWAITED, because this method is async and the sync twin is a no-op in production.
The note above says a literal here "means the last resort does not exist off the default
@@ -5636,7 +5636,7 @@ export class TaskExecutor {
}
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-18:25 (fleet: made ASYNC to own its resolution):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: made ASYNC to own its resolution):
This predicate protects a card from artifact-recovery replanning, and three of its conditions are
lifecycle columns: the terminal pair, and a review row whose auto-merge is off (a human owns it). As
literals they all read false on a renamed board — so a FINISHED card, or a review row a human was
@@ -5820,7 +5820,7 @@ export class TaskExecutor {
* A task re-dispatched by pass 1 is not re-dispatched by pass 2 (dedupe set).
*/
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-01:10:
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40:
The wip-lane read for the two resume sweeps, resolved at PROJECT level.
`listTasks`' `column` option filters in the store, so both sweeps returned an EMPTY array on a
@@ -5956,7 +5956,7 @@ export class TaskExecutor {
}
/*
FNXC:WorkflowResolvedColumns 2026-07-31-19:20 (a MISSED PAIR, the class #2879 ratcheted):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (a MISSED PAIR, the class #2879 ratcheted):
`listWipLaneTasks()` above already resolves the wip lane by role. This filter did not — it re-asserted
the literal `in-progress` on the rows that read returned, so on a renamed board the read found the
orphans and the filter dropped every one.
@@ -7845,7 +7845,7 @@ export class TaskExecutor {
const taskStore = this.store;
const patch: Partial<TaskDetail> = {};
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-01:05:
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40:
Resolve a requested ROLE to this task's own column, because the seam that asks cannot.
`workflow-node-handlers.ts`'s review-handoff seam is a pure function over an IR node and a
@@ -10189,7 +10189,7 @@ export class TaskExecutor {
not move those tasks backward or re-enqueue them. Mirrors the gating the in-review
self-healing sweep (recoverMissingWorktreeReviewFailures) applies before the same recovery.
*/
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:25 (fleet): FN-5147 — with the literal, a renamed board
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): FN-5147 — with the literal, a renamed board
skipped this auto-merge-off gate entirely, so an automatic recovery moved a human-review-terminal
card backward. #2689 converted the terminal guard at the top of this method; this is the other half
of the same decision. */
@@ -10398,7 +10398,7 @@ export class TaskExecutor {
if (abortProvenance === "global-pause" || live.userPaused === true) return false;
if (abortProvenance === "completion-finalize") return false;
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-17:10 (fleet: executor.ts review-lane classifiers, on top of #2689):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: executor.ts review-lane classifiers, on top of #2689):
"IS THIS CARD IN THE REVIEW LANE?" from the task's own workflow. Five pause-abort classifiers asked it
as the default lineage's literal, and each refusal drops the card through to the operator-action park
these paths exist to avoid (FN-6796's benign in-review abort, the manual-merge-hold abort, the two
@@ -10427,7 +10427,7 @@ export class TaskExecutor {
}
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-20:30 (PR #2703 review — greptile P1, and it is the most
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2703 review — greptile P1, and it is the most
important finding in this sweep):
THE SYNCHRONOUS RESOLVER IS A NO-OP IN PRODUCTION. `resolvePlannerLanes` reads
@@ -10465,7 +10465,7 @@ export class TaskExecutor {
if (!isGenericAbortProvenance(abortProvenance)) return false;
if (userCanceled) return false;
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-20:40 (PR #2703 review — replaces my own earlier reasoning):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2703 review — replaces my own earlier reasoning):
This comparison used the SYNC `resolvePlannerLanes`, which I justified as the right resolver for a
synchronous classifier. That justification was wrong in production: in PostgreSQL mode the sync
selection reader always returns undefined, so the sync resolver hands back the DEFAULT workflow's lanes
@@ -10620,7 +10620,7 @@ export class TaskExecutor {
if (!isGenericAbortProvenance(abortProvenance) && abortProvenance !== "global-pause") return false;
if (userCanceled) return false;
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-17:15 (fleet): ONE SNAPSHOT for the entry gate AND the deferred
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): ONE SNAPSHOT for the entry gate AND the deferred
recheck inside `scheduleRetry` below — the recheck is the second half of THIS decision ("is the card
still where it was when we admitted it?"), so resolving the board again inside the timeout callback
would let a workflow edit make the two halves disagree.
@@ -10739,7 +10739,7 @@ export class TaskExecutor {
if (live.paused || live.userPaused === true) return false;
if (live.status != null || live.error != null) return false;
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-17:20 (fleet: executor.ts — the split-snapshot defect):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: executor.ts — the split-snapshot defect):
THE LANES ARE RESOLVED HERE, AT THE TOP, because this method already resolved them — at the very END,
for its return value — while every eligibility check below compared against the default lineage's
literals. On a renamed board the four `in-review` gates all read false, so a card in review skipped the
@@ -10804,7 +10804,7 @@ export class TaskExecutor {
memo?: { lanes?: { hold: string; wip: string; review: string; wipDeclared: boolean } },
): Promise<{ hold: string; wip: string; review: string; wipDeclared: boolean }> {
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-01:00 (PR #2640 review, greptile P2):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2640 review, greptile P2):
ONE RESOLUTION PER RECOVERY, and the reason is correctness as much as I/O. Eligibility and
re-entry ran this separately, so a workflow edit landing between the two calls would have the
two halves of one decision reading DIFFERENT lane sets — the eligibility check admits a card in
@@ -11049,7 +11049,7 @@ export class TaskExecutor {
}
const live = loadedLive;
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-18:40 (fleet: executor.ts handleGraphFailure):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: executor.ts handleGraphFailure):
ONE LANE SNAPSHOT FOR THE WHOLE METHOD, declared where `live` first exists. The three wip comparisons
below run BEFORE the re-entry classifiers' memo was created, so a snapshot declared beside that memo
is used-before-declared — which is how the two halves came to read different boards in the first
@@ -11225,7 +11225,7 @@ export class TaskExecutor {
FNXC:WorkflowLifecycle 2026-06-18-12:00:
FN-6647 closes the remaining durability gap by deriving already-finalized completion from the persisted task row: non-in-progress column, completed steps, no live pause/status/error, and the finalize-to-review log entry. The volatile `completionFinalizedTaskIds` marker still helps within one executor lifecycle, but teardown/restart loss must not reclassify a completed in-review row as a hard-cancel pause abort.
*/
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:32 (fleet): on a renamed board a completed,
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): on a renamed board a completed,
already-finalized row read as still-in-wip, so FN-6644/FN-6647's suppression never fired and the
row was re-parked as an operator-action pause abort — the durability gap those tickets closed. */
const alreadyFinalizedToReview = Boolean(
@@ -11281,11 +11281,11 @@ export class TaskExecutor {
);
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-01:05 (PR #2640 review, greptile P2): one lane
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2640 review, greptile P2): one lane
snapshot for one recovery decision — see `resolveResumeLanes`. Eligibility and re-entry are two
halves of the SAME decision and must not read different boards.
FNXC:WorkflowLifecycleColumns 2026-08-01-17:30 (fleet): the surrounding branches share it now too.
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): the surrounding branches share it now too.
This method asked "still in the wip lane?" in three more places as the default lineage's id while
creating this memo for the classifiers — so the classifiers read the board and the branches around
them read the default names.
@@ -12020,7 +12020,7 @@ export class TaskExecutor {
if (implementationIncompleteMergeFailure && !incompleteSteps) return false;
const prematureMergeWithIncompleteSteps = implementationIncompleteMergeFailure && incompleteSteps;
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-17:40 (fleet: executor.ts — the REVERSE half-conversion):
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: executor.ts — the REVERSE half-conversion):
THE DESTINATION WAS ALREADY RESOLVED HERE AND THE GATE WAS NOT. `resolveReboundColumnFor` below picks
the board's rebound column (U7), but this gate compared against three default-lineage literals — so on
a renamed board the router refused before ever reaching the resolved move. That is the mirror image of
@@ -12570,7 +12570,7 @@ export class TaskExecutor {
// Skip for tasks that are already in-progress, in-review, merging, or done —
// these should not be interrupted and sent back to triage for re-planning.
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-08:10:
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40:
THIS GUARD DID THE EXACT THING ITS OWN COMMENT SAYS IT MUST NOT.
The comment directly above is explicit: skip for tasks already in-progress, in-review, merging or
@@ -12643,7 +12643,7 @@ export class TaskExecutor {
// path below, but we emit a loud audit record so these states stop being
// silent.
/*
FNXC:WorkflowLifecycleColumns 2026-08-01-18:10 (fleet: execute() preflight): THREE DRIFT CHECKS, ONE
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: execute() preflight): THREE DRIFT CHECKS, ONE
SNAPSHOT — merge-confirmed while still executing, stale mergeDetails, and in-wip with no worktree. None
fired on a renamed board, so every recovery they perform silently stopped happening. The third one's own
message says it "usually indicates a partial updateTask/moveTask sequence failed" — a diagnostic that
@@ -12699,7 +12699,7 @@ export class TaskExecutor {
// Check dependencies
const allTasks = await this.store.listTasks({ slim: true, includeArchived: false });
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:50 (batch-engine — dependency satisfaction, per DEPENDENCY):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (batch-engine — dependency satisfaction, per DEPENDENCY):
Resolved from each DEPENDENCY's own workflow, not this task's: dependencies routinely span workflows,
so asking "is my blocker finished?" against the blocked task's vocabulary is the wrong question. That
is the answer main settled on in `branch-group-ops.ts` (#2720) and it is reused here rather than
@@ -13640,7 +13640,7 @@ export class TaskExecutor {
// was unwinding; continuing the cleanup would clobber a valid
// recovery (see the analogous block in the outer finally for the
// full reasoning).
/* FNXC:WorkflowLifecycleColumns 2026-08-01-18:05 (fleet: stuck-requeue family): "has a
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet: stuck-requeue family): "has a
concurrent recovery already moved this card on?" — the pre-completion lanes are the board's
wip and hold. With literals a renamed board always answered "moved on", the cleanup never
ran, and the log line blamed a concurrent recovery that had not happened. */
@@ -14452,7 +14452,7 @@ export class TaskExecutor {
}
const hasExplicitWorktreeBinding = typeof liveTask.worktree === "string" || liveTask.worktree === null;
const hasExplicitBranchBinding = typeof liveTask.branch === "string" || liveTask.branch === null;
/* FNXC:WorkflowLifecycleColumns 2026-08-01-18:15 (fleet): the contract holds while the card is
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): the contract holds while the card is
in ITS board's wip lane; the literal made every renamed-board retry look reclaimed. */
const worktreeContractIntact = liveTask.column === (await this.resolveResumeLanes(task.id)).wip
&& !liveTask.paused
@@ -14921,7 +14921,7 @@ export class TaskExecutor {
this.clearPausedAborted(task.id);
const latestTask = await this.store.getTask(task.id);
if (
/* FNXC:WorkflowLifecycleColumns 2026-08-01-18:18 (fleet): the HOLD lane — this recognises a card the
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): the HOLD lane — this recognises a card the
abort already parked with its progress preserved, and skipping the cleanup is what keeps that
progress. On a renamed board the cleanup ran anyway and discarded it. */
latestTask?.column === (await this.resolveResumeLanes(task.id)).hold &&
@@ -18599,7 +18599,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
};
}
const additionalSkillPaths = mergeAdditionalSkillPaths(skillContext.additionalSkillPaths, ceSkillsDir ? [ceSkillsDir] : undefined);
// FNXC:WorkflowSteps 2026-08-08-00:00:
// FNXC:WorkflowSteps 2026-07-30-21:40:
// FN-8461 / GitHub #2388: workflow steps resolve skills from enabled-plugin
// body directories and the optional CE install root. Warn only after merging
// those sources when THIS named skill remains undiscoverable: a non-empty path
@@ -18726,7 +18726,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
`Workflow step '${workflowStep.name}' using model: ${workflowModelDetails}`,
);
this.setActiveWorkflowStepSession(task.id, session, worktreePath, this.createSeenSteeringIds(task));
// FNXC:TaskTiming 2026-08-01-10:00: graph-owned Plan Review is the only
// FNXC:TaskTiming 2026-07-30-21:40: graph-owned Plan Review is the only
// post-spec planning lane. Start before prompting and finalize in finally before any replan handoff.
const ownsPlanningSegment = workflowStep.id === "graph:plan-review-step" || workflowStep.name === "Plan Review";
if (ownsPlanningSegment) {
@@ -18821,7 +18821,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
if (workflowStep.requiresBrowser === true) {
await logBrowserVerificationActivity(`[browser-verification] finished browser verification for task ${task.id}: timed out`);
}
// FNXC:TaskCost 2026-08-01-10:00: Plan Review tokens are task cost;
// FNXC:TaskCost 2026-07-30-21:40: Plan Review tokens are task cost;
// snapshot before timeout disposal just like normal completion.
await accumulateSessionTokenUsage(this.store, task.id, session, { agentId: task.assignedAgentId ?? undefined, role: "executor" });
try { session.dispose(); } catch { /* best-effort */ }
@@ -21270,7 +21270,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
`${taskId} force-requeue could not read latest task state: ${err instanceof Error ? err.message : String(err)}`,
);
}
/* FNXC:WorkflowLifecycleColumns 2026-08-01-17:52 (fleet): the board's wip lane; with the literal a
/* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (fleet): the board's wip lane; with the literal a
renamed board skipped every force-requeue as "recovered concurrently". */
if (latestColumn && latestColumn !== (await this.resolveResumeLanes(taskId)).wip) {
executorLog.log(

View File

@@ -2833,7 +2833,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-07:10 (the query-filter class, seventeenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, seventeenth sweep):
A task whose steps are ALL done but whose session died before the executor could hand it to review.
The literal read meant that on a renamed board it was never found, so finished implementation work
sat in the wip lane with no session and nothing to move it on — the shape this sweep exists to
@@ -3275,7 +3275,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const now = Date.now();
const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-09:40 (the query-filter class, twenty-first sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-first sweep):
Clears a `merging`/`merging-pr` stamp left on a review card with no live merger behind it. The
literal read meant that on a renamed board the stamp was never cleared, so the card read as
mid-merge forever — and the merge-active stamp is what the merger and the dashboard Retry gate both
@@ -3570,7 +3570,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
/*
FNXC:WorkflowResolvedColumns 2026-07-31-17:40 (the query-filter class, thirty-second sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirty-second sweep):
This read answers "is another LIVE task holding this worktree?" — the same question
`findActiveWorktreeOwner` answers for the executor, and the same failure if it comes back empty: the
checkout reads as unowned and this sweep reclaims a worktree another task is working in.
@@ -3750,7 +3750,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-01:30 (the query-filter class, twelfth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twelfth sweep):
Three lane reads AND three lane guards in the body, so both halves convert together — widening the
read alone would admit renamed-board cards and then mis-decide every one of them (the phantom-binding
check, the blocked-hold skip, and the review triple-proof are all keyed on lane).
@@ -3820,7 +3820,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// per-task override preserves that for override-less tasks while letting
// explicit autoMerge:true tasks recover.
/*
FNXC:WorkflowResolvedColumns 2026-07-31-01:15 (#2879 review — greptile, "multi-role tasks run
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2879 review — greptile, "multi-role tasks run
recovery twice"): DEDUPED ACROSS THE BUCKETS, NOT JUST WITHIN EACH.
`readBucket` dedupes by id, but only inside one role's read. A custom workflow may put more than
@@ -4077,7 +4077,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-11:40 (SELF-AUDIT after #2916 found the same class):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (SELF-AUDIT after #2916 found the same class):
These loop-body lane guards were MISSED when I converted this sweep's read. That is not a
cosmetic gap: this one decides whether the backward move needs `reviewProof`. Left literal,
a renamed review card admitted by the widened read reads as NOT-in-review, so the
@@ -4748,7 +4748,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
return pathsOverlap(dependentScope, blockerScope);
};
/*
FNXC:WorkflowResolvedColumns 2026-07-31-03:05 (the query-filter class, thirteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirteenth sweep):
When a task completes, this releases everything blocked on it. Three literal reads meant that on a
renamed board it released NOTHING — every dependent stayed blocked on a task that had already
finished, which is the most visible form of this class: the board simply stops moving.
@@ -4771,7 +4771,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const inReviewTasks = (await readDependentBucket(completedReviewColumns)).filter((t) => !t.paused);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-02:40 (#2883 review — greptile P1, "duplicate dependent
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2883 review — greptile P1, "duplicate dependent
reconciliation"; same class as #2879 one sweep over):
DEDUPED ACROSS THE BUCKETS, NOT JUST WITHIN EACH.
@@ -4780,7 +4780,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
reconciled twice from the same stale snapshot — the second pass deciding against `blockedBy` state
the first pass had already cleared, and `updateTask`/`logEntry` firing twice for one card.
FNXC:WorkflowResolvedColumns 2026-07-31-04:05 (precedence correction):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (precedence correction):
Written first as `new Map(entries)` with a comment claiming first-bucket precedence. That
constructor keeps first insertion ORDER but the LAST value for a repeated key, so it did the
opposite of what it said. The explicit `has` guard below makes the code match the claim; order is
@@ -4806,7 +4806,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
a degraded board reads a finished dependency as unmet — the exact stall being cleared here.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-31-05:00 (#2883 review — greptile P1, "overbroad
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2883 review — greptile P1, "overbroad
dependency satisfaction"): REUSE THE SCHEDULER'S RESOLVER, DO NOT RE-DERIVE IT.
The first version unioned all three review roles, which counts a `mergeOrchestration`-only
@@ -5690,7 +5690,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const now = Date.now();
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:45 (the query-filter class, eleventh sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, eleventh sweep):
THREE lane reads whose downstream treatment DIFFERS — hold cards seed the queued-dependency pass,
review cards are exempted when paused — so this cannot collapse into one union. Read the project's
columns for all three role groups, dedupe into one map, then classify each card against ITS OWN
@@ -5698,7 +5698,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
and the cards stayed blocked behind dependencies that had long since finished.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:30 (#2876 review — greptile, "traitless workflow
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2876 review — greptile, "traitless workflow
columns stay invisible"): CONFIRMED, DEFERRED, AND THE REASON IS THAT IT IS NOT LOCAL.
`resolveProjectColumnsForRoles` returns its legacy floor plus what workflows DECLARE for the
@@ -5823,7 +5823,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (
!candidates.has(taskId)
/*
FNXC:WorkflowResolvedColumns 2026-07-31-01:40 (FLAGGED AND LEFT COUNTED):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (FLAGGED AND LEFT COUNTED):
This sits in a log-dedup closure defined BEFORE the per-referenced-task lane prefetch below, so
the resolved sets are not in scope here and tsc says so. Hoisting the prefetch above the closure
is not available either — it is keyed on `candidates`, which this closure helps build.
@@ -5843,7 +5843,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
/*
FNXC:WorkflowResolvedColumns 2026-07-31-01:30 (batch-engine — every lane question here is about ANOTHER task):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (batch-engine — every lane question here is about ANOTHER task):
This method classifies why a BLOCKER or a DEPENDENCY is no longer blocking, and those rows routinely
belong to a different workflow than the blocked card. So lanes are resolved PER REFERENCED TASK, not
from the blocked task — the same answer main settled on for dependency satisfaction in
@@ -6699,7 +6699,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async reconcileStaleMergerStatus(): Promise<number> {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-06:40 (the query-filter class, sixteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, sixteenth sweep):
A card that reached a terminal lane while still carrying `merging`/`merging-pr` holds the merger
queue. Two literal reads meant that on a renamed board the stale status was never cleared, so one
finished card blocked the queue for every task behind it.
@@ -8308,7 +8308,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const now = Date.now();
/*
FNXC:WorkflowResolvedColumns 2026-07-31-12:40 (the query-filter class, twenty-fourth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-fourth sweep):
A review card whose STEPS are not finished — it reached review on a graph failure, not on completed
work. The literal read meant that on a renamed board it was never requeued, so the card sat in
review claiming to be done while its own steps said otherwise.
@@ -8742,7 +8742,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const now = Date.now();
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:20 (the query-filter class, twenty-second sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-second sweep):
A GHOST review card — parked in review past the stuck timeout with nobody owning its merge lane.
The literal read meant that on a renamed board it was never found, so the card sat in review
indefinitely with no merger, no session and no timeout ever firing against it.
@@ -8883,7 +8883,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const maxAutoMergeRetries = resolveMaxAutoMergeRetries(settings);
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:55 (the query-filter class, twenty-third sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-third sweep):
A merge that failed for a TRANSIENT reason and burned its whole retry budget. The literal read
meant that on a renamed board the retry budget was never refunded, so a card that failed on a
network blip stayed failed permanently — an operator-visible failure with no operator-visible cause.
@@ -8933,7 +8933,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (!task) continue;
/*
Re-check selector on the full row — the slim snapshot is best-effort and may be stale once we
await. FNXC:WorkflowResolvedColumns 2026-07-31-11:10: this SECOND lane guard converts with the
await. FNXC:WorkflowResolvedColumns 2026-07-30-21:40: this SECOND lane guard converts with the
first. Converting only the read left it rejecting every renamed-board card the widened query
found, and the new test failed on exactly that — the "convert the pair or neither" rule, caught
by the test rather than by reading.
@@ -9279,7 +9279,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// Workspace tasks live in in-review (post-capture/review, pre/partial land). A task already
// done is finished; todo/in-progress are owned by execution-stage reconcilers.
/*
FNXC:WorkflowResolvedColumns 2026-07-31-18:05 (the query-filter class, thirty-third sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirty-third sweep):
A WORKSPACE task lands per-repo, and this re-enqueues one whose lands are partial or zero. The
literal read meant that on a renamed board a workspace task stranded mid-land was never
re-enqueued — some repos landed, some not, and nothing to finish the job.
@@ -9715,7 +9715,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// Done workspace tasks are the canonical "safe to clean" set (their lands are finalized).
/*
FNXC:WorkflowResolvedColumns 2026-07-31-18:10 (the query-filter class, thirty-fourth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirty-fourth sweep):
Removes the per-repo worktrees a finished workspace task left behind. The literal read meant that
on a renamed board they were never removed — disk held by tasks that finished, growing quietly.
@@ -9805,7 +9805,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async recoverDoneTaskMergeMetadata(): Promise<number> {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-17:05 (the query-filter class, thirty-first sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirty-first sweep):
Repairs the merge metadata of a card that already reached the COMPLETE lane — the commit sha an
operator sees, and that later reconcilers trust. The literal read meant that on a renamed board a
done card's metadata was never repaired, so a completed task could keep pointing at a commit that
@@ -10046,7 +10046,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-06:10 (the query-filter class, fifteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, fifteenth sweep):
A task whose merge is CONFIRMED but which never reached the complete lane. Two literal reads meant
that on a renamed board it was never found, so a card whose work is merged sat in review or hold
forever while its commit was already on the base branch.
@@ -10989,7 +10989,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
a renamed board was never listed at all, so it stayed failed with every step done.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-31-00:45 (#2869 review — greptile, "traitless review lanes
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (#2869 review — greptile, "traitless review lanes
remain invisible"): CONFIRMED, DEFERRED, SAME CLASS AS #2876.
A board that renames its review lane but declares NO lifecycle traits contributes nothing to this
@@ -11443,7 +11443,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
/*
FNXC:WorkflowResolvedColumns 2026-07-31-14:10 (the query-filter class, twenty-sixth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-sixth sweep):
A review card whose BRANCH TIP is bound to a different task's work. The literal read meant that on
a renamed board the misbinding was never detected, so the card would merge — or refuse to — against
a branch that is not its own.
@@ -11612,7 +11612,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
if (settings.globalPause || settings.enginePaused) return 0;
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
/*
FNXC:WorkflowResolvedColumns 2026-07-31-04:30 (the query-filter class, fourteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, fourteenth sweep):
Two literal reads, and two per-card `task.column === …` checks inside the filters below. Those
checks were redundant while the query pinned the column; under a resolved read they become the
per-card verdict, so they convert in the same change rather than being deleted.
@@ -11817,7 +11817,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async recoverMisclassifiedFailures(): Promise<number> {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-13:20 (the query-filter class, twenty-fifth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-fifth sweep):
A task the executor parked `failed` for "no fn_task_done" whose steps are ALL actually done — the
failure is a misclassification, not real. The literal read meant that on a renamed board the error
was never cleared, so finished work stayed visibly failed and never entered normal review.
@@ -12068,7 +12068,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async auditNoCommitsExpectedCandidates(): Promise<number> {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-15:30 (the query-filter class, twenty-eighth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-eighth sweep):
Audits cards that finished every step but pushed NO commits — either a legitimately commit-free task
that never declared itself so, or work that silently produced nothing. The literal read meant that on
a renamed board only the `no_commits` ERROR path fed the audit, so a card sitting quietly in a
@@ -12238,7 +12238,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-07:40 (the query-filter class, eighteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, eighteenth sweep):
A card holding a wip slot with NO worktree, NO branch and no step started — nothing is running and
nothing will. The literal read meant that on a renamed board it was never found, so the card kept
its slot indefinitely and the capacity it holds is denied to work that could actually run.
@@ -12432,7 +12432,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async recoverOrphanedExecutions(): Promise<number> {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-08:10 (the query-filter class, nineteenth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, nineteenth sweep):
WHAT THIS SWEEP RESTORES IS VISIBILITY, NOT A REPAIR. It takes no lifecycle action — it only emits
`task:orphan-detected-no-action` so an operator can see a wip card with no live session behind it.
The literal read meant that on a renamed board the event was never emitted, so the one signal
@@ -12538,7 +12538,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
/*
FNXC:WorkflowResolvedColumns 2026-07-31-08:40 (the query-filter class, twentieth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twentieth sweep):
Reattaches a DURABLE AGENT to a task it is still assigned to but has stopped executing. The literal
read meant that on a renamed board the reattach never fired, so the agent's own assignment was
never resumed and the card sat assigned-but-idle — visibly owned by an agent that had gone quiet.
@@ -13388,7 +13388,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
async recoverNoProgressNoTaskDoneFailures(): Promise<number> {
try {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-16:05 (the query-filter class, twenty-ninth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-ninth sweep):
A wip card the executor failed for "no fn_task_done" that made NO step progress and left no git
work — nothing to salvage, so it is safe to requeue. The literal read meant that on a renamed board
it was never requeued, so a card that produced nothing sat failed while still holding its wip slot.
@@ -13496,7 +13496,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-14:45 (the query-filter class, twenty-seventh sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, twenty-seventh sweep):
THE NOTE BELOW SAID THIS WAS UNFIXABLE. It called the literal query "unfixable without a
project-level lane resolution before the read" — which is precisely what
`resolveProjectColumnsForRoles` provides; it did not exist when that note was written. The wiring
@@ -13510,7 +13510,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
const tasks = [...missingWtById.values()];
/*
FNXC:WorkflowLifecycleColumns 2026-08-02-20:20 (PR #2745 review — greptile P1: "recovery lanes are not
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2745 review — greptile P1: "recovery lanes are not
wired", and it is right):
THE PRODUCTION PATH SUPPLIES THE SET. Adding the optional parameter to the three classifiers gave them the
capability and changed nothing in production, which is a half-conversion of a different shape: not a gate
@@ -13524,7 +13524,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
*/
const recoveryIrCache = new Map<string, WorkflowIr>();
/*
FNXC:WorkflowResolvedColumns 2026-07-31-14:50 (the ARITY trap, same seam):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the ARITY trap, same seam):
These classifiers take a MEMBERSHIP set, and `resolveTaskLifecycleColumns().review` is the FIRST
column per role — so a board declaring more than one review column contributed only one of them and
a card sitting in the others read as not-in-review. `columnsWithFlag` over the three review roles is
@@ -13672,7 +13672,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) return 0;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-16:35 (the query-filter class, thirtieth sweep):
FNXC:WorkflowResolvedColumns 2026-07-30-21:40 (the query-filter class, thirtieth sweep):
A review card failed for "no fn_task_done" that DID make step progress — real work exists, so it is
retried rather than discarded. The literal read meant that on a renamed board the retry never fired,
so partially-completed work was parked failed with its retry budget untouched: the budget exists
@@ -14117,7 +14117,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
* them up for a fresh planning attempt.
*/
/**
* FNXC:TaskTiming 2026-08-01-10:00:
* FNXC:TaskTiming 2026-07-30-21:40:
* A planning anchor is safe because triage ownership and graph Plan Review are
* exclusive. Recovery finalizes only when neither in-process owner is live;
* the atomic null-check makes restart and repeated maintenance idempotent.
@@ -14146,7 +14146,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
if (applied) {
finalized++;
// FNXC:TaskTiming 2026-08-01-12:00: this recovery is operator-auditable
// FNXC:TaskTiming 2026-07-30-21:40: this recovery is operator-auditable
// without persisting duration prose; the atomically finalized task id
// and fixed no-live-owner reason are sufficient forensic evidence.
await this.store.recordRunAuditEvent?.({

View File

@@ -175,7 +175,6 @@
"packages/engine/src/__tests__/scheduler-paused-dispatch-refusal.test.ts": 3,
"packages/engine/src/__tests__/self-blocked-dependency-deadlock.pg.test.ts": 1,
"packages/engine/src/__tests__/self-healing-db-corruption.test.ts": 1,
"packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts": 6,
"packages/engine/src/__tests__/stale-task-reporter.test.ts": 1,
"packages/engine/src/__tests__/task-completion-dependency-lanes.test.ts": 1,
"packages/engine/src/__tests__/transition-pending-recovery-deadlock.pg.test.ts": 2,
@@ -201,7 +200,6 @@
"packages/engine/src/concurrency.ts": 5,
"packages/engine/src/ephemeral-worker-manager.ts": 2,
"packages/engine/src/eval-followups.ts": 2,
"packages/engine/src/executor.ts": 40,
"packages/engine/src/ipc/__tests__/ipc-host.test.ts": 1,
"packages/engine/src/ipc/__tests__/ipc-worker.test.ts": 1,
"packages/engine/src/merger.ts": 2,
@@ -219,7 +217,6 @@
"packages/engine/src/runtimes/__tests__/child-process-worker.test.ts": 3,
"packages/engine/src/runtimes/in-process-runtime.ts": 4,
"packages/engine/src/scheduler.ts": 9,
"packages/engine/src/self-healing.ts": 8,
"packages/engine/src/stale-task-reporter.ts": 2,
"packages/engine/src/task-completion.ts": 1,
"packages/engine/src/triage.ts": 9,