fleet: pure lifecycle predicates 17 → 5 — a monitoring signal that went quiet, and a blocker that waited forever (#2745)

**Claimed on #2742 before starting.** Four pure modules — **17 → 5**,
every survivor flagged with a reason.

All four are **pure functions with no store**, so the fix shape is the
injected-set contract established in #2728, not an in-function resolve.

## Three failures that never error

| predicate | what a renamed board got |
|---|---|
| `getTaskAgeStalenessSignal` | `undefined` for **every** card —
age-staleness reported nothing |
| `isStaleBlockedByBlocker` | "not stale" for a blocker that was
finished, paused in review, or retry-exhausted |
| `areAllDependenciesDone` | "not satisfied" for a dependency that had
landed |

The first is the one to sit with: **a monitoring signal that goes quiet
is indistinguishable from health.** The board looks fine while cards sit
for days, and nobody investigates a metric that isn't alarming. The
signal also chose its *threshold pair* by wip-vs-review, so both halves
were literal.

The second means the blocked card **waited forever**, silently — "not
stale" is the answer that produces no event.

The third is the **third place** "satisfied" is asked. It now gives the
same answer as the store's `blockedBy` computation (#2720) and the merge
blocker: complete or archived, unioned with the legacy ids. Three
surfaces, one rule — which is exactly why I refused to settle it inside
a vocabulary sweep the first two times it came up.

## Optional is load-bearing

Both halves are asserted for every predicate: supplying lanes makes a
renamed board work, **omitting them preserves every existing caller**.

A *required* parameter would have compiled at every call site and then
answered "not active" / "not stale" / "not satisfied" for everything.
That is the silent direction, and **no type checker catches it** — which
is the argument for optional-plus-legacy-default over a clean signature.

The restart-recovery classifiers (with-progress / no-progress /
merge-active) take the same set, and **the combiner threads it to all
three**, so a caller cannot convert the outer question and leave an
inner one literal. `isInReviewMissingWorktreeSessionStartFailure` is
deliberately untouched — #2728 converts it and duplicating that would
conflict.

## The five that remain

- **3 are the ternary trait-fallback branches** (`lanes ? … : legacy`) —
the documented degradation path the census counts by design, not
unconverted guards. I am not marking them `DELIBERATE-LITERAL` to move
the number; that marker means "a lifecycle literal reviewed and kept",
and mislabelling to flatter a count is how the instrument stops meaning
anything.
- **`recoverInterruptedRuns`' filter sits behind a `listTasks({ column:
"in-progress" })` query.** The query is the live filter, so converting
the redundant predicate moves the census and changes nothing an operator
sees. **Third file** where the reported guard is the inert copy and the
real one is a query.
- **`resolveWorkflowBypassGuards` is sync and receives only column
strings** — no task, no store. Converting it means adding lanes to
`MoveTaskOptions` and threading them from the moves path, which another
worker owns. Marked `DELIBERATE-LITERAL` as an explicit hand-off, with
the consequence named: on a renamed board the operator's drag out of the
wip lane was rejected by the transition validator, so **a card could not
be cancelled from the board at all** (AGENTS.md's Move-Task hard-cancel
contract).

## Verification

`pnpm test:gate` **10 / 158 / 487 / 71** · 9 new cases, **5 red on
revert** · 13/13 with the archive PG suite · `tsc` clean in core and
engine · `pnpm lint` clean · census **17 → 5**.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 06:48:00 -07:00
committed by GitHub
parent e0010f241e
commit 61b82a2737
8 changed files with 336 additions and 31 deletions

View File

@@ -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>): 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");
}
});
});

View File

@@ -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<string>; review?: ReadonlySet<string> },
): 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<string> = new Set(["done", "archived"]);
const LEGACY_REVIEW_COLUMNS: ReadonlySet<string> = 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;

View File

@@ -1181,8 +1181,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async selectNextTaskForAgent( agentId: string, agent?: Pick<Agent, "id" | "role"> & Partial<Pick<Agent, "runtimeConfig">>, ): Promise<InboxTask | null> {
return selectNextTaskForAgentImpl(this, agentId, agent);
}
public areAllDependenciesDone(dependencies: string[], tasksById: Map<string, Task>): boolean {
return areAllDependenciesDoneImpl(this, dependencies, tasksById);
public areAllDependenciesDone(dependencies: string[], tasksById: Map<string, Task>, satisfiedColumns?: ReadonlySet<string>): boolean {
return areAllDependenciesDoneImpl(this, dependencies, tasksById, satisfiedColumns);
}
public async readTaskForMove(id: string): Promise<Task> {
return readTaskForMoveImpl(this, id);

View File

@@ -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<string, unknown>).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<string>(["done", "archived"]);
const satisfiedIrCache = new Map<string, WorkflowIr>();
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;
}

View File

@@ -75,13 +75,32 @@ export async function recordBranchGroupMemberLandedImpl(store: TaskStore,
});
}
export function areAllDependenciesDoneImpl(store: TaskStore, dependencies: string[], tasksById: Map<string, Task>): 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<string, Task>,
satisfiedColumns?: ReadonlySet<string>,
): 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<string> = new Set(["done", "archived"]);
export function resolveWorkflowBypassGuardsImpl(store: TaskStore,
moveSource: NonNullable<MoveTaskOptions["moveSource"]>,
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";
}

View File

@@ -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<string>,
): 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<string>,
): 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<string>(MERGE_ACTIVE_MISSING_WORKTREE_STATUSES);
export function isMergeActiveMissingWorktreeSessionStartFailure(task: Task): boolean {
return task.column === "in-review"
export function isMergeActiveMissingWorktreeSessionStartFailure(
task: Task,
reviewColumns?: ReadonlySet<string>,
): 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<string>,
): 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<void> {
/*
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);

View File

@@ -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<string, WorkflowIr>();
const reviewColumnsFor = async (taskId: string): Promise<ReadonlySet<string>> => {
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";

View File

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