diff --git a/packages/core/src/__tests__/project-lane-vocabulary.test.ts b/packages/core/src/__tests__/project-lane-vocabulary.test.ts index 63a0ef8b6b..a38a4be351 100644 --- a/packages/core/src/__tests__/project-lane-vocabulary.test.ts +++ b/packages/core/src/__tests__/project-lane-vocabulary.test.ts @@ -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"]); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0aacdacfb6..4f8a28573d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/project-lane-vocabulary.ts b/packages/core/src/project-lane-vocabulary.ts index 7e3fea8679..4cf7f2fc36 100644 --- a/packages/core/src/project-lane-vocabulary.ts +++ b/packages/core/src/project-lane-vocabulary.ts @@ -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 = { * @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, + options: ProjectLaneResolutionOptions = {}, ): Promise> { const columns = new Set(); + /* Collected while walking the definitions so the widening needs no second read. */ + const declaredColumnIds = new Set(); + 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; } diff --git a/packages/core/src/task-store/async-comments-attachments.ts b/packages/core/src/task-store/async-comments-attachments.ts index 1d9cf43a24..ced82e8b74 100644 --- a/packages/core/src/task-store/async-comments-attachments.ts +++ b/packages/core/src/task-store/async-comments-attachments.ts @@ -485,7 +485,7 @@ export async function listTaskDocuments( archivedColumns?: ReadonlySet,): Promise { 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,): Promise { 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 — diff --git a/packages/core/src/task-store/comments-ops.ts b/packages/core/src/task-store/comments-ops.ts index 1538076adb..6a92973251 100644 --- a/packages/core/src/task-store/comments-ops.ts +++ b/packages/core/src/task-store/comments-ops.ts @@ -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 — diff --git a/packages/core/src/task-store/task-artifacts-ops.ts b/packages/core/src/task-store/task-artifacts-ops.ts index 00af3a88fc..11d3c18cdf 100644 --- a/packages/core/src/task-store/task-artifacts-ops.ts +++ b/packages/core/src/task-store/task-artifacts-ops.ts @@ -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 — diff --git a/packages/core/src/task-store/task-id-integrity.ts b/packages/core/src/task-store/task-id-integrity.ts index e2aa91cf27..49b75e90e6 100644 --- a/packages/core/src/task-store/task-id-integrity.ts +++ b/packages/core/src/task-store/task-id-integrity.ts @@ -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 — diff --git a/packages/engine/src/__tests__/executor-resume-lanes-resolved.test.ts b/packages/engine/src/__tests__/executor-resume-lanes-resolved.test.ts index 3ed646d060..bf1a73229f 100644 --- a/packages/engine/src/__tests__/executor-resume-lanes-resolved.test.ts +++ b/packages/engine/src/__tests__/executor-resume-lanes-resolved.test.ts @@ -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. diff --git a/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts b/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts index c2d50a92de..61b27537c7 100644 --- a/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts +++ b/packages/engine/src/__tests__/resolved-read-with-literal-filter.test.ts @@ -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 diff --git a/packages/engine/src/__tests__/self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts b/packages/engine/src/__tests__/self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts index 10542186f9..47d358d07a 100644 --- a/packages/engine/src/__tests__/self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts +++ b/packages/engine/src/__tests__/self-healing-converted-sweeps-have-no-literal-lane-guards.test.ts @@ -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. diff --git a/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts b/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts index 9f64d994ba..7fff0fd016 100644 --- a/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts +++ b/packages/engine/src/__tests__/self-healing-query-filter-blindness.test.ts @@ -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. diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index d478cdb1ba..bdd3c021e2 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -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 — diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 69ad821d28..19106be76c 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -361,7 +361,7 @@ function mergeAdditionalSkillPaths(...pathGroups: Array): } /** - * 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>>, ): Promise { /* - 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(); /** - * 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 = {}; /* - 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( diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 5e9b1cb2a5..9f63f24150 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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 { 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(); /* - 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 { 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(); /* - 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(); /* - 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 { 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 { 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 { 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 { 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(); /* - 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?.({ diff --git a/scripts/lib/fnxc-future-dates-baseline.json b/scripts/lib/fnxc-future-dates-baseline.json index 6e4d5a1b63..8b47d93f3e 100644 --- a/scripts/lib/fnxc-future-dates-baseline.json +++ b/scripts/lib/fnxc-future-dates-baseline.json @@ -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,