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:
163
packages/core/src/__tests__/pure-lifecycle-predicates.test.ts
Normal file
163
packages/core/src/__tests__/pure-lifecycle-predicates.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user