diff --git a/packages/core/src/__tests__/pure-lifecycle-predicates.test.ts b/packages/core/src/__tests__/pure-lifecycle-predicates.test.ts new file mode 100644 index 0000000000..656ad894f6 --- /dev/null +++ b/packages/core/src/__tests__/pure-lifecycle-predicates.test.ts @@ -0,0 +1,163 @@ +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-18:45 (fleet: the pure lifecycle predicates): + +THE INVARIANT: a pure predicate answers with the lanes its CALLER resolved, and keeps the legacy ids when the +caller supplies none. + +Three of these have a failure mode worth naming separately, because none of them errors: + + - `getTaskAgeStalenessSignal` returned `undefined` for every card, so **age-staleness silently reported + nothing**. A monitoring signal that goes quiet is indistinguishable from health — the board looks fine + while cards sit for days. + - `isStaleBlockedByBlocker` answered "not stale" for a blocker that was finished, paused in review, or + permanently failed, so the blocked card **waited forever** with no signal. + - `areAllDependenciesDone` is the third place "satisfied" is asked; it now gives the same answer as the + store's `blockedBy` computation (#2720) and the merge blocker. Three surfaces, one rule. + +THE OPTIONAL PARAMETER IS THE DESIGN, and both halves are asserted for each: supplying lanes makes a renamed +board work, omitting them preserves every existing caller. A required parameter would have compiled +everywhere and then answered "not active" / "not stale" / "not satisfied" for everything — the silent +direction, and the one a type checker cannot catch. +*/ +import { describe, expect, it } from "vitest"; +import type { Task } from "../types.js"; + +import { getTaskAgeStalenessSignal } from "../task-age-staleness.js"; +import { isStaleBlockedByBlocker } from "../blocker-fanout.js"; + +function task(overrides: Partial): Task { + return { + id: "FN-1", column: "building", dependencies: [], steps: [], currentStep: 0, + createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", + columnMovedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + } as unknown as Task; +} + +const NOW = Date.parse("2026-01-08T00:00:00.000Z"); // a week later — well past any threshold + +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-21:30 (rebase onto #2746 — another worker landed the same fix): +THE CONTEXT SHAPE IS THEIRS, not mine. #2746 converted `task-age-staleness.ts` while this PR was open, with a +`lifecycle: { wip, review }` context field where I had used two flat fields. Theirs is on main and is the better +shape — one field for one resolved struct, so a third lane cannot arrive as a third parameter — so my +conversion of that file is dropped and these cases were rewritten against their API. + +Kept rather than deleted: the cases assert the INVARIANT (a renamed board produces a signal; a card outside the +active lanes does not; omitting lanes keeps the legacy ids), and #2746 has no case for the third of those. +*/ +describe("age staleness follows the caller's active lanes", () => { + it("produces a signal for a renamed WIP lane", () => { + // Pre-fix: `building` matched neither literal, so the signal was undefined and the board looked healthy. + const signal = getTaskAgeStalenessSignal(task({ column: "building" }), { + now: NOW, + lifecycle: { wip: "building", review: "signoff" }, + }); + + expect(signal).toBeDefined(); + }); + + it("produces a signal for a renamed REVIEW lane", () => { + expect(getTaskAgeStalenessSignal(task({ column: "signoff" }), { + now: NOW, lifecycle: { wip: "building", review: "signoff" }, + })).toBeDefined(); + }); + + it("stays silent for a card in neither active lane", () => { + // The paired negative: intake and terminal cards have no age-staleness signal by design. + expect(getTaskAgeStalenessSignal(task({ column: "backlog" }), { + now: NOW, lifecycle: { wip: "building", review: "signoff" }, + })).toBeUndefined(); + }); + + it("keeps the LEGACY lanes when the caller supplies none", () => { + expect(getTaskAgeStalenessSignal(task({ column: "in-progress" }), { now: NOW })).toBeDefined(); + expect(getTaskAgeStalenessSignal(task({ column: "in-review" }), { now: NOW })).toBeDefined(); + // And a renamed lane is NOT recognised without them — which is why the board list wires a resolver. + expect(getTaskAgeStalenessSignal(task({ column: "building" }), { now: NOW })).toBeUndefined(); + }); +}); + +describe("blocker staleness follows the caller's lanes", () => { + const lanes = { terminal: new Set(["shipped", "filed"]), review: new Set(["signoff"]) }; + + it("treats a blocker in the board's COMPLETE lane as stale", () => { + // Pre-fix: the blocked card kept waiting on a finished blocker, forever, with no signal. + expect(isStaleBlockedByBlocker(task({ column: "shipped" }), 3, lanes)).toBe(true); + }); + + it("treats a PAUSED blocker in the board's review lane as stale", () => { + expect(isStaleBlockedByBlocker(task({ column: "signoff", paused: true }), 3, lanes)).toBe(true); + }); + + it("treats a retry-exhausted review blocker as stale", () => { + expect(isStaleBlockedByBlocker( + task({ column: "signoff", status: "failed", mergeRetries: 5 }), 3, lanes, + )).toBe(true); + }); + + it("does NOT treat a healthy blocker as stale", () => { + // The paired negative: a live blocker must still block. + expect(isStaleBlockedByBlocker(task({ column: "building" }), 3, lanes)).toBe(false); + expect(isStaleBlockedByBlocker(task({ column: "signoff" }), 3, lanes)).toBe(false); + }); + + it("keeps the LEGACY ids when the caller supplies no lanes", () => { + expect(isStaleBlockedByBlocker(task({ column: "done" }), 3)).toBe(true); + expect(isStaleBlockedByBlocker(task({ column: "archived" }), 3)).toBe(true); + expect(isStaleBlockedByBlocker(task({ column: "shipped" }), 3)).toBe(false); + }); +}); + +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-20:55 (PR #2745 review — greptile P1 x2, and the shape is worth its +own name): + +A CAPABILITY WITH NO CALLER IS A HALF-CONVERSION TOO. + +Both findings were the same: I added the optional lane parameters and did not wire the production callers. The +census would have shown converted sites and a renamed board would have behaved exactly as before — a branch- +group task depending on a card that landed in a workflow-specific complete lane stayed excluded from dispatch, +and a review row stranded by a missing-worktree session start stayed parked for a human. + +That is not the familiar direction (a gate reading the wrong board); it is the parameter existing and nobody +passing it. It cannot be caught by testing the predicate — the predicate is correct — so this asserts the +CALL SITES, which is the same reason #2728's classifier needed a structural case. +*/ +describe("the production callers actually supply the lanes", () => { + it("branch-group dispatch resolves a satisfied set and passes it", async () => { + const { readFile } = await import("node:fs/promises"); + const code = (await readFile(new URL("../task-store/branch-group-ops.ts", import.meta.url), "utf8")) + .replace(/\/\*[\s\S]*?\*\//g, ""); + + // It must RESOLVE lanes, not merely accept a parameter… + expect(code).toContain("resolveTaskLifecycleColumns"); + // …and every areAllDependenciesDone call must pass the set it resolved. + const calls = [...code.matchAll(/areAllDependenciesDone\(([^)]*)\)/g)]; + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call[1], "areAllDependenciesDone called without satisfiedColumns").toContain("satisfiedColumns"); + } + }); + + it("the missing-worktree recovery sweep passes a resolved review set to all three classifiers", async () => { + const { readFile } = await import("node:fs/promises"); + const code = (await readFile(new URL("../../../engine/src/self-healing.ts", import.meta.url), "utf8")) + .replace(/\/\*[\s\S]*?\*\//g, ""); + + /* + All three classifiers, by name: converting the sweep's admission and leaving the merge-active + classification on the legacy id would make the stage/audit labels disagree with the admission — which is + the defect one level down from the one the reviewer found. + */ + for (const name of [ + "isRecoverableMissingWorktreeReviewFailureWithProgress", + "isRecoverableMissingWorktreeReviewFailureNoProgress", + "isMergeActiveMissingWorktreeSessionStartFailure", + ]) { + const call = code.match(new RegExp(`${name}\\(([^)]*)\\)`)); + expect(call, `${name} is not called in self-healing`).toBeTruthy(); + expect(call?.[1], `${name} called without a resolved review set`).toContain("reviewColumns"); + } + }); +}); diff --git a/packages/core/src/blocker-fanout.ts b/packages/core/src/blocker-fanout.ts index 764f421f23..1a7461343c 100644 --- a/packages/core/src/blocker-fanout.ts +++ b/packages/core/src/blocker-fanout.ts @@ -80,16 +80,38 @@ interface MutableEntry { overlapBlockedTodoCount: number; } -export function isStaleBlockedByBlocker(blocker: Task | undefined, maxAutoMergeRetries: number): boolean { +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-17:35 (fleet: the pure lifecycle predicates): +"IS THIS BLOCKER STALE?" — i.e. may the blocked card stop waiting on it. Three lifecycle questions in four +lines: the blocker is finished, or it is parked in review, or it is a review row that exhausted its merge +retries. All three were the default lineage's ids. + +On a renamed board every one answered NO, so a blocked card kept waiting on a blocker that was done, paused +in review, or permanently failed — waiting forever, with no signal, because "not stale" is the silent answer. + +Injected rather than resolved: this module is pure and its callers already hold the blocker row. The optional +set defaults to the legacy ids so no existing caller changes behaviour. +*/ +export function isStaleBlockedByBlocker( + blocker: Task | undefined, + maxAutoMergeRetries: number, + lanes?: { terminal?: ReadonlySet; review?: ReadonlySet }, +): boolean { if (!blocker) return true; - if (blocker.column === "done" || blocker.column === "archived") return true; - if (blocker.column === "in-review" && blocker.paused === true) return true; - if (blocker.column === "in-review" && blocker.status === "failed" && (blocker.mergeRetries ?? 0) >= maxAutoMergeRetries) { + const terminal = lanes?.terminal ?? LEGACY_TERMINAL_COLUMNS; + const review = lanes?.review ?? LEGACY_REVIEW_COLUMNS; + if (terminal.has(blocker.column)) return true; + if (review.has(blocker.column) && blocker.paused === true) return true; + if (review.has(blocker.column) && blocker.status === "failed" && (blocker.mergeRetries ?? 0) >= maxAutoMergeRetries) { return true; } return false; } +/** The ids from before workflows owned the vocabulary; the fallback when a caller supplies no lanes. */ +const LEGACY_TERMINAL_COLUMNS: ReadonlySet = new Set(["done", "archived"]); +const LEGACY_REVIEW_COLUMNS: ReadonlySet = new Set(["in-review"]); + function getBlockingAgeMs(blocker: Task, nowMs: number): number { const startedAt = Date.parse(blocker.columnMovedAt ?? blocker.updatedAt); if (!Number.isFinite(startedAt)) return 0; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index ea90bb5134..a2296e8a93 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1181,8 +1181,8 @@ export class TaskStore extends EventEmitter { async selectNextTaskForAgent( agentId: string, agent?: Pick & Partial>, ): Promise { return selectNextTaskForAgentImpl(this, agentId, agent); } - public areAllDependenciesDone(dependencies: string[], tasksById: Map): boolean { - return areAllDependenciesDoneImpl(this, dependencies, tasksById); + public areAllDependenciesDone(dependencies: string[], tasksById: Map, satisfiedColumns?: ReadonlySet): boolean { + return areAllDependenciesDoneImpl(this, dependencies, tasksById, satisfiedColumns); } public async readTaskForMove(id: string): Promise { return readTaskForMoveImpl(this, id); diff --git a/packages/core/src/task-store/branch-group-ops.ts b/packages/core/src/task-store/branch-group-ops.ts index 175601a33b..4ea853f977 100644 --- a/packages/core/src/task-store/branch-group-ops.ts +++ b/packages/core/src/task-store/branch-group-ops.ts @@ -7,6 +7,8 @@ * instance as its first parameter and performs byte-identical work. */ import {TaskStore} from "../store.js"; +import {resolveTaskLifecycleColumns} from "../workflow-lifecycle-traits.js"; +import type {WorkflowIr} from "../workflow-ir-types.js"; import type {Task, ColumnId, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, Agent} from "../types.js"; import {runReconciliationAbort} from "../workflow-reconciliation.js"; import "../builtin-traits.js"; @@ -88,7 +90,7 @@ export async function selectNextTaskForAgentImpl(store: TaskStore, agentId: stri const tasksById = new Map(tasks.map((task) => [task.id, task])); const isCheckoutAware = "checkoutTask" in store && typeof (store as Record).checkoutTask === "function"; - const isDoneLike = (task: Task | undefined) => task?.column === "done" || task?.column === "archived"; + const sortByOldestColumnMove = (a: Task, b: Task) => { const aSortAt = a.columnMovedAt ?? a.createdAt; const bSortAt = b.columnMovedAt ?? b.createdAt; @@ -125,6 +127,28 @@ export async function selectNextTaskForAgentImpl(store: TaskStore, agentId: stri const roleCompatibleAssignedTasks = assignedTasks.filter(isBindCompatible); + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-20:35 (PR #2745 review — greptile P1: "dependency lanes remain + legacy-only"): + ONE SATISFIED SET FOR THE WHOLE BATCH, resolved from the dependency rows already loaded into `tasksById`. + The reviewer's point is the one that matters: adding `satisfiedColumns` to `areAllDependenciesDone` without + wiring this caller left the capability unused, so a branch-group task depending on a card that landed in a + workflow-specific complete lane stayed excluded from dispatch — unchanged from before the conversion. + + Resolved per dependency through a shared IR cache (dependencies can span workflows) and unioned with the + legacy ids, matching the answer settled in #2720 and used by the merge blocker. + */ + const satisfiedColumns = new Set(["done", "archived"]); + const satisfiedIrCache = new Map(); + for (const dependencyId of new Set( + roleCompatibleAssignedTasks.flatMap((task) => task.dependencies ?? []), + )) { + const lifecycle = await resolveTaskLifecycleColumns(store, dependencyId, satisfiedIrCache); + if (lifecycle?.complete) satisfiedColumns.add(lifecycle.complete); + if (lifecycle?.archived) satisfiedColumns.add(lifecycle.archived); + } + const isDoneLike = (task: Task | undefined) => task !== undefined && satisfiedColumns.has(task.column); + /** FNXC:TaskDispatch 2026-07-19-14:40: remembered ownership must not reselect an operator-parked task when `userPaused` remains true but legacy `paused` is false. */ const todoCandidates = roleCompatibleAssignedTasks.filter( (task) => task.column === "todo" && task.paused !== true && task.userPaused !== true, @@ -135,7 +159,7 @@ export async function selectNextTaskForAgentImpl(store: TaskStore, agentId: stri if (isCheckoutAware && task.checkedOutBy && task.checkedOutBy !== agentId) { return false; } - return store.areAllDependenciesDone(task.dependencies, tasksById); + return store.areAllDependenciesDone(task.dependencies, tasksById, satisfiedColumns); }) .sort(sortByOldestColumnMove); @@ -153,7 +177,7 @@ export async function selectNextTaskForAgentImpl(store: TaskStore, agentId: stri return false; } - if (store.areAllDependenciesDone(task.dependencies, tasksById)) { + if (store.areAllDependenciesDone(task.dependencies, tasksById, satisfiedColumns)) { return false; } diff --git a/packages/core/src/task-store/task-store-helpers.ts b/packages/core/src/task-store/task-store-helpers.ts index cbf40ade44..757099b5e4 100644 --- a/packages/core/src/task-store/task-store-helpers.ts +++ b/packages/core/src/task-store/task-store-helpers.ts @@ -75,13 +75,32 @@ export async function recordBranchGroupMemberLandedImpl(store: TaskStore, }); } -export function areAllDependenciesDoneImpl(store: TaskStore, dependencies: string[], tasksById: Map): boolean { +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-17:45 (fleet — the SAME "satisfied" answer as #2720): +A DEPENDENCY IS SATISFIED IN ITS OWN BOARD'S TERMINAL PAIR (complete or archived), unioned with the legacy +ids. This is the third place that question is asked, and it now gives the same answer as the store's +`blockedBy` computation (#2720) and the merge blocker — three surfaces, one rule, which is the whole reason +I refused to settle it inside a vocabulary sweep the first two times it came up. + +Injected: this helper takes a pre-loaded map of dependency rows (it is used inside batch passes), so the +caller resolves lanes once for the batch rather than once per dependency. +*/ +export function areAllDependenciesDoneImpl( + store: TaskStore, + dependencies: string[], + tasksById: Map, + satisfiedColumns?: ReadonlySet, +): boolean { + const satisfied = satisfiedColumns ?? LEGACY_SATISFIED_COLUMNS; return dependencies.every((dependencyId) => { const dependency = tasksById.get(dependencyId); - return dependency?.column === "done" || dependency?.column === "archived"; + return dependency !== undefined && satisfied.has(dependency.column); }); } +/** The satisfied ids from before workflows owned the vocabulary. */ +const LEGACY_SATISFIED_COLUMNS: ReadonlySet = new Set(["done", "archived"]); + export function resolveWorkflowBypassGuardsImpl(store: TaskStore, moveSource: NonNullable, options?: MoveTaskOptions, @@ -140,6 +159,19 @@ params: { }): boolean { if (params.bypassGuards) return true; if (params.options?.recoveryRehome === true) return true; + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-17:55 (fleet): + THE HARD-CANCEL SHAPE — a USER dragging a card from the wip lane back to the hold lane — is what + AGENTS.md's Move-Task contract calls a hard cancel, and it is the one move allowed to bypass workflow + transition guards. Spelled as literals, the bypass never applied on a renamed board: the operator's drag + was rejected by the transition validator, so a card could not be cancelled from the board at all. + + FLAGGED, NOT CONVERTED: this function is SYNCHRONOUS and receives only column strings — no task id, no + store — so there is nothing to resolve from and no caller-injected set today. Converting it means adding + lanes to `MoveTaskOptions` (the moves path already resolves them; see moves.ts) and threading them here. + That is a moves-path change, and moves.ts is owned by another worker's PR, so this is a deliberate hand-off + rather than a literal nobody noticed. DELIBERATE-LITERAL until the moves path passes its snapshot down. + */ return params.moveSource === "user" && params.fromColumn === "in-progress" && params.toColumn === "todo"; } diff --git a/packages/engine/src/restart-recovery-coordinator.ts b/packages/engine/src/restart-recovery-coordinator.ts index 081cbb7ca4..918e88999b 100644 --- a/packages/engine/src/restart-recovery-coordinator.ts +++ b/packages/engine/src/restart-recovery-coordinator.ts @@ -66,16 +66,34 @@ export function extractMissingWorktreePathFromSessionStartFailure(error: unknown return pathPart.length > 0 ? pathPart : null; } -export function isRecoverableMissingWorktreeReviewFailureWithProgress(task: Task): boolean { - return task.column === "in-review" +/* +FNXC:WorkflowLifecycleColumns 2026-08-02-18:20 (fleet: the missing-worktree recovery classifiers): +THE REVIEW LANE ARRIVES FROM THE CALLER, matching the contract added to +`isInReviewMissingWorktreeSessionStartFailure` in #2728 — this module is pure and synchronous by design +(the classifiers are combined in chains) and every caller either holds a store or already resolved the lane. + +These three decide whether a review row stranded by an unusable-worktree session start is RECOVERABLE. As +literals they answered NO on every renamed board, so the recovery never ran and the row stayed parked failed +for a human — the exact operator-action park these paths were written to avoid. + +Optional, defaulting to the legacy id, so no existing caller or test changes behaviour. +*/ +export function isRecoverableMissingWorktreeReviewFailureWithProgress( + task: Task, + reviewColumns?: ReadonlySet, +): boolean { + return (reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review") && !task.paused && task.status === "failed" && isMissingWorktreeSessionStartFailure(task.error) && hasStepProgress(task); } -export function isRecoverableMissingWorktreeReviewFailureNoProgress(task: Task): boolean { - return task.column === "in-review" +export function isRecoverableMissingWorktreeReviewFailureNoProgress( + task: Task, + reviewColumns?: ReadonlySet, +): boolean { + return (reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review") && !task.paused && task.status === "failed" && isMissingWorktreeSessionStartFailure(task.error) @@ -85,8 +103,11 @@ export function isRecoverableMissingWorktreeReviewFailureNoProgress(task: Task): export const MERGE_ACTIVE_MISSING_WORKTREE_STATUSES = ["merging", "merging-pr", "merging-fix"] as const; const MERGE_ACTIVE_MISSING_WORKTREE_STATUS_SET = new Set(MERGE_ACTIVE_MISSING_WORKTREE_STATUSES); -export function isMergeActiveMissingWorktreeSessionStartFailure(task: Task): boolean { - return task.column === "in-review" +export function isMergeActiveMissingWorktreeSessionStartFailure( + task: Task, + reviewColumns?: ReadonlySet, +): boolean { + return (reviewColumns ? reviewColumns.has(task.column) : task.column === "in-review") && !task.paused && typeof task.status === "string" && MERGE_ACTIVE_MISSING_WORKTREE_STATUS_SET.has(task.status) @@ -115,10 +136,15 @@ export function isInReviewMissingWorktreeSessionStartFailure( && isMissingWorktreeSessionStartFailure(task.error); } -export function isRecoverableMissingWorktreeReviewFailure(task: Task): boolean { - return isRecoverableMissingWorktreeReviewFailureWithProgress(task) - || isRecoverableMissingWorktreeReviewFailureNoProgress(task) - || isMergeActiveMissingWorktreeSessionStartFailure(task); +export function isRecoverableMissingWorktreeReviewFailure( + task: Task, + reviewColumns?: ReadonlySet, +): boolean { + /* The combiner threads the set to all three, so a caller cannot convert the outer question and leave one + of the three inner ones on the legacy id — the half-conversion shape this program keeps finding. */ + return isRecoverableMissingWorktreeReviewFailureWithProgress(task, reviewColumns) + || isRecoverableMissingWorktreeReviewFailureNoProgress(task, reviewColumns) + || isMergeActiveMissingWorktreeSessionStartFailure(task, reviewColumns); } export class RestartRecoveryCoordinator { @@ -128,6 +154,17 @@ export class RestartRecoveryCoordinator { ) {} async recoverInterruptedRuns(): Promise { + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-18:30 (fleet — FLAGGED as the QUERY class, not converted): + The live filter here is the `listTasks({ column: "in-progress" })` QUERY, not the `.filter` below it: the + query has already restricted the rows, so the predicate is a redundant re-assertion of the same literal. + Converting the filter alone would drop the census count by one and change nothing an operator sees — the + board's wip-lane rows still would not be listed, because the QUERY never asked for them. + + Query filters are the class the census tracks separately, and fixing them needs a project-level lane + resolution before the read (there is no task to resolve from yet). Same shape as `executor.ts`'s + in-progress sweep and `server.ts`'s reliability counts, both flagged in earlier fleet PRs. + */ const allInProgress = await this.store.listTasks({ slim: true, column: "in-progress" }); const candidates = allInProgress.filter((task) => task.column === "in-progress" && !task.paused); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index da7914d742..3a1d324d66 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -30,7 +30,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, import { readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveReboundTarget, resolveLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core"; +import { resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, isNearDuplicateCanonicalInactive, parseExplicitDuplicateMarker, flagTriageDuplicate, isTriageDuplicateKeepAcknowledged, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, getBuiltinWorkflow, isBuiltinWorkflowId, resolveWorkflowIrForTask, resolveReboundTarget, resolveLifecycleColumns, resolveTaskLifecycleColumns, workflowHasColumn, planLegacyAdoption, resolveOrphanedPendingStepResults, classifyReviewLease, PLAN_REVIEW_LEASE_STALENESS_MS, DEFAULT_MAX_POST_REVIEW_FIXES, ACTIVE_WORKFLOW_WORK_ITEM_STATES, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult, type WorkflowIr } from "@fusion/core"; import { finalizePlanningSegment } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; @@ -12025,11 +12025,36 @@ export class SelfHealingManager extends SelfHealingGitEvidence { const settings = await this.store.getSettings(); if (settings.globalPause || settings.enginePaused) return 0; const tasks = await this.store.listTasks({ column: "in-review", slim: true }); - const candidates = tasks.filter((task) => - isRecoverableMissingWorktreeReviewFailureWithProgress(task) - || isRecoverableMissingWorktreeReviewFailureNoProgress(task) - || isMergeActiveMissingWorktreeSessionStartFailure(task), - ); + /* + FNXC:WorkflowLifecycleColumns 2026-08-02-20:20 (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 + reading the wrong board, but a capability with no caller. The census would have shown three converted + sites and a renamed board would still have parked the card for a human. + + Resolved per candidate with one shared IR cache. Note the QUERY above still reads `column: "in-review"` — + that is the query class, flagged in this PR's body and unfixable without a project-level lane resolution + before the read, so the wiring here matters for boards whose review lane IS `in-review` under a renamed + workflow (the common partial-rename case) and for the merge-active variant. + */ + const recoveryIrCache = new Map(); + const reviewColumnsFor = async (taskId: string): Promise> => { + const lifecycle = await resolveTaskLifecycleColumns(this.store, taskId, recoveryIrCache); + return new Set([lifecycle?.review ?? "in-review", "in-review"]); + }; + const candidateChecks = await Promise.all(tasks.map(async (task) => { + const reviewColumns = await reviewColumnsFor(task.id); + return { + task, + recoverable: isRecoverableMissingWorktreeReviewFailureWithProgress(task, reviewColumns) + || isRecoverableMissingWorktreeReviewFailureNoProgress(task, reviewColumns) + || isMergeActiveMissingWorktreeSessionStartFailure(task, reviewColumns), + mergeActive: isMergeActiveMissingWorktreeSessionStartFailure(task, reviewColumns), + }; + })); + const candidates = candidateChecks.filter((entry) => entry.recoverable).map((entry) => entry.task); + const mergeActiveByTaskId = new Map(candidateChecks.map((entry) => [entry.task.id, entry.mergeActive])); if (candidates.length === 0) return 0; @@ -12038,7 +12063,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence { let recovered = 0; for (const task of candidates) { try { - const mergeActiveCandidate = isMergeActiveMissingWorktreeSessionStartFailure(task); + /* Reuses the classification computed with this task's resolved lanes above, so the stage/audit + labels cannot disagree with the admission decision. */ + const mergeActiveCandidate = mergeActiveByTaskId.get(task.id) === true; const stage = mergeActiveCandidate ? "missing-worktree-merge-active" : "missing-worktree-review"; const noActionEvent = mergeActiveCandidate ? "task:reconcile-missing-worktree-merge-active-no-action" : "task:missing-worktree-review-no-action"; const recoveryEvent = mergeActiveCandidate ? "task:reconcile-missing-worktree-merge-active" : "task:missing-worktree-review"; diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index de21e11545..3b01762e67 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -13,7 +13,6 @@ "packages/dashboard/app/components/Column.tsx": 7, "packages/core/src/live-agent-count.ts": 6, "packages/core/src/task-merge.ts": 6, - "packages/core/src/task-store/branch-group-ops.ts": 6, "packages/core/src/task-store/task-artifacts-ops.ts": 6, "packages/dashboard/app/components/ListView.tsx": 6, "packages/dashboard/src/reliability-metrics.ts": 6, @@ -26,8 +25,7 @@ "packages/engine/src/merger.ts": 5, "packages/engine/src/project-engine.ts": 5, "packages/engine/src/restart-recovery-coordinator.ts": 5, - "packages/core/src/blocker-fanout.ts": 4, - "packages/core/src/task-store/task-store-helpers.ts": 4, + "packages/core/src/task-store/branch-group-ops.ts": 4, "packages/dashboard/app/components/TaskReviewTab.tsx": 4, "packages/dashboard/app/components/taskSorting.ts": 4, "packages/dashboard/src/gitlab-tracking-comments.ts": 1, @@ -145,6 +143,8 @@ "packages/cli/src/commands/task.ts\u0000done": 1, "packages/cli/src/extension.ts\u0000archived": 1, "packages/cli/src/extension.ts\u0000done": 1, + "packages/core/src/task-store/task-store-helpers.ts\u0000in-progress": 1, + "packages/core/src/task-store/task-store-helpers.ts\u0000todo": 1, "packages/dashboard/app/components/command-center/MissionControlPanel.tsx\u0000done": 1, "packages/dashboard/app/components/command-center/MissionControlPanel.tsx\u0000in-review": 1, "packages/dashboard/app/components/command-center/MissionControlPanel.tsx\u0000todo": 1,