fleet(engine): self-healing 23 → 6 — async-reachable guards, plus 3 of 4 fan-out guards the sync path could not serve (#3094)
**Replaces #3093, which I am closing.** Third rebuild of this work. ## A coordination note first, because it is costing more than the code `self-healing.ts` has had **three** overlapping conversions land from other lanes while my branch was open — #3049, #3075, #3078. Every time, replaying my commits produced conflicts that were all the same shape: *same guard, two spellings, different variable names*. Each rebuild is a full cycle spent on merge mechanics rather than on lanes. I have rebuilt against `main`'s own census each time rather than argue about whose spelling wins, and this PR contains only what `main` (23) does not have. But if this file is going to keep receiving concurrent fleet passes, one lane should own it — otherwise the next PR pays the same tax again. ## Converted | site | note | |---|---| | `isPhantomExecutorBinding` | caller resolved `lanesOfReclaim(task.id).wip` **three lines above the call**, then passed a task whose column the predicate compared against `in-progress` | | `isWorkspaceOwnerLive` | required `completeColumns` | | `recoverPausedAbortFailures` **body** | #3075 converted this sweep's *router* and left three body guards comparing ids | | `reconcilePreExecutionWorktrees` | a four-id literal in a sweep that **removes worktrees** | | `recoverStarvedRefinementTriageTasks` | 2 peer counts that read zero, so escalation never fired | | `evaluateParkedAgentTaskLink` | the omitted `parkedColumns` argument | All through the **async** `resolveProjectColumnsForRoles`, whose only store read is `listWorkflowDefinitions()` — answerable under PostgreSQL. That is what separates these from the inert kind. **The half-converted sweep is the important one.** A router that resolves correctly feeding a body that compares ids is worse than converting neither: the route now fires on a renamed board and the body then acts on the wrong lane. The `moveTask` **target** is the sharp end — an undeclared target is rejected *except* under `recoveryRehome` with a legacy id (`moves.ts:570`, the #1411 escape hatch), so a converted route feeding the literal `"todo"` rehomes the card into a column its workflow does not declare, which is the state other reconcilers exist to repair. **A real dropped-behaviour bug**: `evaluateParkedAgentTaskLink` was called without `parkedColumns`, falling back to `LEGACY_PARKED_COLUMNS`. A live durable agent linked to a card resting in a renamed hold lane read as not-parked, so the safeguard preserving its task link never applied. ## Withdrawn: the `task:moved` fan-out I wrote the sync-IR conversion, measured it, removed it. `getTaskWorkflowSelectionImpl` returns `undefined` **unconditionally** under PostgreSQL, so `resolveTaskWorkflowIrSync` always answers with the default builtin IR and `columnsWithFlag` on it yields exactly the legacy ids — inert on every board. Worse than the literal, because **the literal is counted**. My own test passed only because its store mock supplied a renamed IR: it pinned the helper's shape, not production behaviour. The refutation is recorded in place, and `check-inert-sync-lane-conversions` exits 0 on this branch. ## One question, one answer An earlier pass of this work mapped the notification-attach guard onto a wider `activeWork` set, and `self-healing-paused-abort-recovery > "rehomes an in-progress pause-abort park back to todo"` caught it — an in-progress park attached a transition notification it should not have. The fix is not a narrower set. Both guards ask **one** question — *"is the card already at the requeue target?"* — which the literal happened to spell twice as `=== "todo"`. The target now resolves once, before the write, and both read it. Deriving one question two ways is exactly how a converted guard and an unconverted target drift apart. ## Census | | before | after | |---|---|---| | `self-healing.ts` | 23 | **11** | | repo backlog | 53 | **41** | ## Measured - `src/__tests__/self-healing*` + `task-agent*` — **42 files / 836 tests pass** - `tsc --noEmit -p packages/engine` clean; **`check-inert-sync-lane-conversions` exits 0**; census `--strict`, `check-lane-wiring`, `check-fnxc-future-dates` clean ## The remaining 11, flagged not guessed - **4** — the fan-out, withdrawn above; blocked on a sync-capable selection reader. - **1** — the log-dedup closure: pre-existing flag; it sits before the lane prefetch it needs, and the degraded answer costs a duplicate log line, not a lifecycle decision. - **1** — the synthetic `{ column: "todo" }` for a *missing* task: deliberate, and now correct rather than unconverted, because `parkedColumns` is legacy-seeded. - The rest are status/deliberate classifications the census counts but that are not lane guards. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved workflow automation for boards using renamed or customized workflow lanes. * Fixed task completion fan-out, branch rebinding, recovery, and stalled-task detection across custom lifecycle columns. * Prevented completion actions from triggering when tasks move back to the work-in-progress lane. * Improved cleanup and pause recovery behavior for customized workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -178,13 +178,22 @@ describe("self-healing completion fan-out", () => {
|
||||
store.emit("task:moved", { task: t, from: "in-review", to: "done", source: "user" });
|
||||
store.emit("task:moved", { task: t, from: "done", to: "archived", source: "engine" });
|
||||
store.emit("task:moved", { task: t, from: "in-review", to: "todo", source: "user" });
|
||||
await Promise.resolve();
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:40:
|
||||
`await Promise.resolve()` was draining exactly one microtask, which coupled this case to the
|
||||
number of awaits inside a FIRE-AND-FORGET path. The listener does not await the fan-out and never
|
||||
did, so how many microtasks it takes is not the contract — "it ran, twice, for the right
|
||||
transitions" is. Resolving the lanes adds an await, so the drain is now written against the
|
||||
invariant instead of against the old await count.
|
||||
*/
|
||||
await vi.waitFor(() => { expect(spy).toHaveBeenCalledTimes(2); });
|
||||
expect(spy).toHaveBeenNthCalledWith(1, "FN-L", { worktreeHint: undefined });
|
||||
|
||||
mgr.stop();
|
||||
store.emit("task:moved", { task: t, from: "in-review", to: "done", source: "user" });
|
||||
await Promise.resolve();
|
||||
/* The negative keeps a real drain: an unwired listener must stay silent after several ticks,
|
||||
not merely after one. */
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -201,3 +210,85 @@ describe("self-healing completion fan-out", () => {
|
||||
expect((await store.getTask("FN-D"))?.branch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:45:
|
||||
THE `task:moved` FAN-OUT ON A RENAMED BOARD.
|
||||
|
||||
Two of this listener's guards were keyed on `in-review`/`done`/`archived`, so on a board using none
|
||||
of those ids a card entering its own review lane never had its branch rebound, and a card reaching
|
||||
its own complete or archive lane never ran the completion fan-out — the worktree was never reclaimed
|
||||
and dependents kept a `blockedBy` pointing at a blocker that had already finished.
|
||||
|
||||
The SYNC-IR conversion of this listener is inert and was withdrawn. These two guards gate work the
|
||||
listener already `void`s, so they can ask the ASYNC resolver instead without changing anything an
|
||||
observer can see; the resolution reads `listWorkflowDefinitions()`, which is answerable under
|
||||
PostgreSQL.
|
||||
|
||||
The board-stall counter above them is deliberately NOT converted here: it mutates in-memory state in
|
||||
the handler's own tick, so it is the one guard that genuinely needs a synchronous answer.
|
||||
*/
|
||||
describe("the task:moved fan-out resolves the board's own lanes", () => {
|
||||
/** Review `checking`, complete `shipped`, archive `filed` — no legacy id anywhere. */
|
||||
const RENAMED_IR = {
|
||||
version: "v2", id: "wf-renamed", name: "renamed", nodes: [], edges: [],
|
||||
columns: [
|
||||
{ id: "building", name: "Building", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] },
|
||||
{ id: "checking", name: "Checking", traits: [{ trait: "merge" }] },
|
||||
{ id: "shipped", name: "Shipped", traits: [{ trait: "complete" }] },
|
||||
{ id: "filed", name: "Filed", traits: [{ trait: "archived" }] },
|
||||
],
|
||||
};
|
||||
|
||||
function renamedStore(task: Task) {
|
||||
const base = createStore([task]) as unknown as TaskStore & EventEmitter;
|
||||
(base as unknown as { listWorkflowDefinitions: unknown }).listWorkflowDefinitions =
|
||||
vi.fn(async () => [{ ir: RENAMED_IR }]);
|
||||
return base;
|
||||
}
|
||||
|
||||
it("runs the completion fan-out for the board's own review -> complete transition", async () => {
|
||||
const t = makeTask("FN-R1", { column: "shipped" });
|
||||
const store = renamedStore(t);
|
||||
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
const spy = vi.spyOn(mgr, "reconcileCompletedTask").mockResolvedValue({ blockedByCleared: 0, worktreeRemoved: false, branchRemoved: false });
|
||||
|
||||
mgr.start();
|
||||
store.emit("task:moved", { task: t, from: "checking", to: "shipped", source: "engine" });
|
||||
|
||||
await vi.waitFor(() => { expect(spy).toHaveBeenCalledWith("FN-R1", { worktreeHint: undefined }); });
|
||||
mgr.stop();
|
||||
});
|
||||
|
||||
it("rebinds the branch on a move into the board's own review lane", async () => {
|
||||
const t = makeTask("FN-R2", { column: "checking" });
|
||||
const store = renamedStore(t);
|
||||
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
const rebind = vi.spyOn(mgr, "reconcileInReviewBranchRebind").mockResolvedValue(0 as never);
|
||||
|
||||
mgr.start();
|
||||
store.emit("task:moved", { task: t, from: "building", to: "checking", source: "engine" });
|
||||
|
||||
await vi.waitFor(() => { expect(rebind).toHaveBeenCalledWith({ includeTaskIds: new Set(["FN-R2"]) }); });
|
||||
mgr.stop();
|
||||
});
|
||||
|
||||
/*
|
||||
The paired negative. The conversion widens membership, so it must not fan out on every move: a
|
||||
`checking -> building` bounce is not a completion, and reconciling it would remove the worktree of
|
||||
a card that is about to run again.
|
||||
*/
|
||||
it("does NOT run the completion fan-out for a bounce back into the wip lane", async () => {
|
||||
const t = makeTask("FN-R3", { column: "building" });
|
||||
const store = renamedStore(t);
|
||||
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||
const spy = vi.spyOn(mgr, "reconcileCompletedTask").mockResolvedValue({ blockedByCleared: 0, worktreeRemoved: false, branchRemoved: false });
|
||||
|
||||
mgr.start();
|
||||
store.emit("task:moved", { task: t, from: "checking", to: "building", source: "engine" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
mgr.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, columnsWithFlag, 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, type WorkflowIr,
|
||||
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, resolveWorkflowIrForTaskWithProvenance, resolveReboundTarget, columnsWithFlag, 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,
|
||||
LEGACY_COLUMN_IDS_BY_ROLE,
|
||||
TERMINAL_ROLES,
|
||||
resolveProjectColumnsForRoles,
|
||||
@@ -1165,9 +1165,19 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
this. (Distinct from `isWorkspaceTaskLive`, which probes the session REGISTRY; this probes the
|
||||
task ROW lifecycle.)
|
||||
*/
|
||||
private isWorkspaceOwnerLive(owner: Task | null | undefined): boolean {
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:40:
|
||||
`completeColumns` is REQUIRED and resolved by the caller (async, once per sweep) because this
|
||||
predicate is sync and its caller is not.
|
||||
|
||||
MEMBERSHIP of `complete` ONLY, deliberately: the literal it replaces was `done` alone, and the
|
||||
comment above spells out that every non-terminal state must read LIVE so a lease is never yanked
|
||||
from a task that could still be running. Adding `archived` would widen what counts as terminal and
|
||||
reclaim leases the old code kept — a behaviour change riding inside a column conversion.
|
||||
*/
|
||||
private isWorkspaceOwnerLive(owner: Task | null | undefined, completeColumns: ReadonlySet<string>): boolean {
|
||||
if (!owner) return false; // not found / deleted → terminal.
|
||||
if (owner.column === "done") return false;
|
||||
if (completeColumns.has(owner.column)) return false;
|
||||
if (owner.status === "failed") return false;
|
||||
return true;
|
||||
}
|
||||
@@ -1397,11 +1407,20 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
* FN-7566: the FN-6736 liveness gate (`agentPresent` heartbeat, `checkedOutBy` lease, `hasRecentRunAudit`) is structurally blind to EPHEMERAL EXECUTOR agents (`agentId: "executor"`): they never emit heartbeat runs (so `activeHeartbeatTaskIds` never contains them), never acquire a checkout lease (`checkedOutBy` stays null), and normal execution activity (sandbox:run / task:log / verification) writes no `runAuditEvents` rows (so `getRecentRunAuditActivityAgeMs` stays null). With all three permanently false, the ONLY surviving gate was age > graceMs*3 (~30 min), so any ephemeral executor task running longer than 30 minutes — a heavy foreach workflow, a slow model — was killed mid-flight on the next self-healing sweep and hard-moved to `todo`, corrupting overlapping-worktree/task-link state.
|
||||
* The fix adds the in-process live-session truth that DOES track ephemeral executors: a worktree path registered as active in `activeSessionRegistry` (the executor/step-session/workflow-step session holds it for the whole run), the `executingTaskLock`, or `isTaskActive`. This mirrors the canonical `isWorkspaceTaskLive` / `sessionDead` predicate. A genuinely leaked binding (FN-6736) still has an EMPTY registry / no lock / inactive task, so legitimate phantom recovery is preserved; a live ephemeral executor now vetoes the phantom verdict regardless of the durable-agent signals.
|
||||
*/
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:40:
|
||||
`wipColumns` is REQUIRED and supplied by the caller, which resolved the SAME set three lines above
|
||||
the call (`lanesOfReclaim(task.id).wip`) to decide whether to invoke this at all, and then passed a
|
||||
task whose column this predicate compared against the `in-progress` literal. Two reads of one
|
||||
board, one resolved and one not — three lines apart.
|
||||
*/
|
||||
private isPhantomExecutorBinding(task: Task, options: {
|
||||
executionAgeMs: number | null;
|
||||
graceMs: number;
|
||||
activeHeartbeatTaskIds: Set<string>;
|
||||
lastActivityMs: number | null;
|
||||
/** Wip MEMBERSHIP — replaces the `in-progress` literal. */
|
||||
wipColumns: ReadonlySet<string>;
|
||||
}): { phantom: boolean; metadata: Record<string, unknown> } {
|
||||
const normalizedId = task.id.toUpperCase();
|
||||
const agentPresent = options.activeHeartbeatTaskIds.has(normalizedId);
|
||||
@@ -1434,7 +1453,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
};
|
||||
|
||||
return {
|
||||
phantom: task.column === "in-progress"
|
||||
phantom: options.wipColumns.has(task.column)
|
||||
&& worktreeExists
|
||||
&& options.executionAgeMs !== null
|
||||
&& options.executionAgeMs > safeAgeMs
|
||||
@@ -1523,6 +1542,40 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
};
|
||||
this.store.on("settings:updated", this.settingsListener);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:50 (FLAGGED AND LEFT COUNTED — a conversion I wrote,
|
||||
measured, and withdrew):
|
||||
|
||||
These four guards are dead on a renamed board: the stall counter reads zero, the review rebind
|
||||
never runs, and the completion fan-out never reclaims a worktree or clears a dependent's
|
||||
`blockedBy`. All real. The obvious conversion does NOT fix them.
|
||||
|
||||
`task:moved` is emitted synchronously, so an `await` here defers everything after it to a
|
||||
microtask and reorders this handler against every other subscriber — which points at the store's
|
||||
sync IR path, the way the scheduler's `resolveTaskParkedColumnsSync` does. I built exactly that
|
||||
and it is INERT:
|
||||
|
||||
`getTaskWorkflowSelectionImpl` returns `undefined` UNCONDITIONALLY — "Backend mode cannot
|
||||
synchronously read PostgreSQL" — so `resolveTaskWorkflowIrSync` always takes its `!workflowId`
|
||||
branch and answers with the DEFAULT builtin IR. `columnsWithFlag` on that IR yields exactly
|
||||
`todo / in-progress / in-review / done / archived`. Identical to the literals, on every board.
|
||||
|
||||
Worse than the literal, because the literal is COUNTED: four guards would leave the census, the
|
||||
file would read as converted, and the next reader would have no reason to look. My own test
|
||||
passed only because its store mock supplied a renamed IR — it pinned the helper's shape, not
|
||||
production behaviour. Same defect as #3051's ten scheduler guards; see #3058 and the call-site
|
||||
allow-list in `sync-workflow-ir-callsite-allowlist.test.ts`.
|
||||
|
||||
SCOPE, after the async conversion below: this note now covers ONLY the board-stall counter, which
|
||||
mutates in-memory state in the handler's own tick and so genuinely needs a synchronous answer. The
|
||||
other two guards gate work the listener already `void`s, so they ask the ASYNC resolver instead —
|
||||
see the note on them. Splitting the four this way is the whole finding: "the listener is sync" was
|
||||
never the real constraint, "this particular guard's ANSWER is consumed synchronously" is.
|
||||
|
||||
THE REAL BLOCKER for the counter is a sync path that can answer for a CUSTOM workflow, and there
|
||||
are two things in the way, not one (`sync-workflow-ir-second-blocker.test.ts`). Until then the
|
||||
literals here stay, counted.
|
||||
*/
|
||||
this.taskMovedFanoutListener = ({ task, from, to }) => {
|
||||
if (
|
||||
from === "in-progress"
|
||||
@@ -1532,19 +1585,57 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
// In-memory only counter; resets on engine restart.
|
||||
this.boardStallWindow.transitionsOutOfInProgressInWindow++;
|
||||
}
|
||||
if (to === "in-review") {
|
||||
void this.reconcileInReviewBranchRebind({ includeTaskIds: new Set([task.id]) }).catch((err: unknown) => {
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:35 (the ASYNC path, which the sync one could not be):
|
||||
THESE TWO GUARDS GATE WORK THAT WAS ALREADY FIRE-AND-FORGET, so they can ask the async resolver
|
||||
without changing anything an observer can see.
|
||||
|
||||
The sync-IR conversion of this listener is inert and was withdrawn (see the note above). But
|
||||
that is a constraint on the SYNC path, and only the board-stall counter above genuinely needs a
|
||||
synchronous answer — it mutates in-memory state in the handler's own tick. The two calls below
|
||||
are already `void`-ed: the listener does not await them, and their bodies already begin at a
|
||||
microtask boundary.
|
||||
|
||||
Moving the guard into that same boundary therefore preserves the ordering property the withdrawn
|
||||
conversion would have broken. An async IIFE runs synchronously only up to its first `await`,
|
||||
which here is the first statement, so the rest of this listener continues in the same tick
|
||||
exactly as before — identical to how `void this.reconcileCompletedTask(...)` already behaved.
|
||||
|
||||
The resolution goes through the ASYNC `resolveProjectColumnsForRoles`, whose only store read is
|
||||
`listWorkflowDefinitions()` — answerable under PostgreSQL, and unaffected by either of the two
|
||||
blockers that make the sync path inert (`sync-workflow-ir-second-blocker.test.ts`).
|
||||
|
||||
What this fixes, on every renamed board: a card entering the board's own review lane never had
|
||||
its branch rebound, and a card reaching the board's own complete or archive lane never ran the
|
||||
completion fan-out — so its worktree was never reclaimed and its dependents kept a `blockedBy`
|
||||
pointing at a blocker that had already finished.
|
||||
*/
|
||||
void (async () => {
|
||||
/* One await, not three: the fan-out is fire-and-forget, so its deferral is unobservable, but
|
||||
there is no reason to add microtasks the resolution does not need. */
|
||||
const [review, complete, archived] = await Promise.all([
|
||||
resolveProjectColumnsForRoles(this.store, REVIEW_ROLES),
|
||||
resolveProjectColumnsForRoles(this.store, ["complete"]),
|
||||
resolveProjectColumnsForRoles(this.store, ["archived"]),
|
||||
]);
|
||||
const lanes = { review, complete, archived };
|
||||
if (lanes.review.has(to)) {
|
||||
await this.reconcileInReviewBranchRebind({ includeTaskIds: new Set([task.id]) }).catch((err: unknown) => {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`[self-healing] task:moved in-review rebind failed for ${task.id}: ${errorMessage}`);
|
||||
});
|
||||
}
|
||||
const shouldReconcile =
|
||||
(lanes.review.has(from) && lanes.complete.has(to)) ||
|
||||
(lanes.complete.has(from) && lanes.archived.has(to));
|
||||
if (!shouldReconcile) return;
|
||||
await this.reconcileCompletedTask(task.id, { worktreeHint: task.worktree ?? undefined }).catch((err: unknown) => {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`[self-healing] task:moved in-review rebind failed for ${task.id}: ${errorMessage}`);
|
||||
log.warn(`[self-healing] task:moved completion fan-out failed for ${task.id}: ${errorMessage}`);
|
||||
});
|
||||
}
|
||||
const shouldReconcile =
|
||||
(from === "in-review" && to === "done") ||
|
||||
(from === "done" && to === "archived");
|
||||
if (!shouldReconcile) return;
|
||||
void this.reconcileCompletedTask(task.id, { worktreeHint: task.worktree ?? undefined }).catch((err: unknown) => {
|
||||
})().catch((err: unknown) => {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.warn(`[self-healing] task:moved completion fan-out failed for ${task.id}: ${errorMessage}`);
|
||||
log.warn(`[self-healing] task:moved fan-out lane resolution failed for ${task.id}: ${errorMessage}`);
|
||||
});
|
||||
};
|
||||
this.store.on("task:moved", this.taskMovedFanoutListener);
|
||||
@@ -3956,6 +4047,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
executionAgeMs,
|
||||
graceMs: STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS,
|
||||
activeHeartbeatTaskIds: activeTaskIds,
|
||||
wipColumns: lanesOfReclaim(task.id).wip,
|
||||
lastActivityMs,
|
||||
});
|
||||
|
||||
@@ -9792,6 +9884,10 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const entries = activeSessionRegistry.entriesByKind("workspace-repo-land");
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
/* FNXC:WorkflowResolvedColumns 2026-07-31-23:40: resolved once per sweep, AFTER the early
|
||||
return above, so a board with no leases pays nothing. See `isWorkspaceOwnerLive`. */
|
||||
const leaseOwnerCompleteColumns = await resolveProjectColumnsForRoles(this.store, ["complete"]);
|
||||
|
||||
const graceMs = settings.taskStuckTimeoutMs ?? STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS;
|
||||
const staleFloorMs = graceMs * PHANTOM_EXECUTOR_BINDING_AGE_MULTIPLIER;
|
||||
const activeMergeTaskId = this.options.getActiveMergeTaskId?.() ?? null;
|
||||
@@ -9821,7 +9917,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const owner = await this.store.getTask(entry.taskId).catch(() => null);
|
||||
const ownerColumn = owner?.column ?? "deleted";
|
||||
// Only a DEMONSTRABLY TERMINAL owner's lease is reclaimed (review C fix).
|
||||
if (this.isWorkspaceOwnerLive(owner)) continue;
|
||||
if (this.isWorkspaceOwnerLive(owner, leaseOwnerCompleteColumns)) continue;
|
||||
|
||||
activeSessionRegistry.unregisterPath(entry.path);
|
||||
await createRunAuditor(this.store, {
|
||||
@@ -9870,13 +9966,24 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
if (settings.globalPause || settings.enginePaused) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
/* FNXC:WorkflowResolvedColumns 2026-07-31-23:40: resolved once per sweep — see the guard below. */
|
||||
const preExecLiveColumns = await resolveProjectColumnsForRoles(
|
||||
this.store,
|
||||
["intake", "hold", "countsTowardWip", ...REVIEW_ROLES, "complete"],
|
||||
);
|
||||
const parked = await this.store.listTasks({ slim: true });
|
||||
const candidates = parked.filter((task) => {
|
||||
if (!task.worktree || task.deletedAt) return false;
|
||||
// Execution evidence — the worktree may hold real work; only the merge/archive lifecycle owns it.
|
||||
if (task.firstExecutionAt || task.executionStartedAt) return false;
|
||||
// Columns where a card is active or queued to become active.
|
||||
if (task.column === "todo" || task.column === "in-progress" || task.column === "in-review" || task.column === "done") return false;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:40:
|
||||
Columns where a card is active or queued to become active — resolved MEMBERSHIP. An EXCLUSION
|
||||
from a sweep that REMOVES worktrees, so a legacy-seeded superset can only be conservative;
|
||||
the literal's failure mode was the dangerous direction, treating a live card on a renamed
|
||||
board as pre-execution and removing its worktree.
|
||||
*/
|
||||
if (preExecLiveColumns.has(task.column)) return false;
|
||||
/*
|
||||
WAITING is not PARKED. A card paused for an operator decision, carrying any status (planning,
|
||||
needs-replan, awaiting-*), blocked on another task, or scheduled for a recovery attempt is
|
||||
@@ -12212,15 +12319,36 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
: undefined;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:45:
|
||||
THE ROUTER WAS CONVERTED (#3075) AND THESE BODY GUARDS WERE NOT — the half-converted state
|
||||
that is worse than converting nothing, because the route now fires correctly on a renamed
|
||||
board and the body then acts on the wrong lane.
|
||||
|
||||
The TARGET is the dangerous one: `moveTask` REJECTS a column the workflow does not declare,
|
||||
EXCEPT under `recoveryRehome` with a legacy id (`moves.ts:570`, the #1411 escape hatch). A
|
||||
converted route feeding the literal `"todo"` rehomes the card into an undeclared column —
|
||||
the state other reconcilers exist to repair.
|
||||
|
||||
`resolveTaskLifecycleColumns` is first-match per role: wrong for "is this card waiting",
|
||||
exactly right for "where should it land". Precedence hold -> intake -> legacy is the one
|
||||
`moves.ts:1296` documents, because a workflow may declare intake and no hold.
|
||||
|
||||
Both guards below ask ONE question — "is the card already at the requeue target?" — which
|
||||
the literal happened to spell twice. Resolved once, before the write, and read by both:
|
||||
deriving one question two ways is how a converted guard and an unconverted target drift.
|
||||
*/
|
||||
const requeueLifecycle = await resolveTaskLifecycleColumns(this.store, task.id);
|
||||
const requeueTarget = requeueLifecycle?.hold ?? requeueLifecycle?.intake ?? "todo";
|
||||
await this.store.updateTask(task.id, {
|
||||
status: null,
|
||||
error: null,
|
||||
...(fresh.column === "todo" && workflowTransitionNotification
|
||||
...(fresh.column === requeueTarget && workflowTransitionNotification
|
||||
? { workflowTransitionNotification }
|
||||
: {}),
|
||||
});
|
||||
if (route.kind === "node-requeue" && fresh.column !== "todo") {
|
||||
await this.store.moveTask(task.id, "todo", {
|
||||
if (route.kind === "node-requeue" && fresh.column !== requeueTarget) {
|
||||
await this.store.moveTask(task.id, requeueTarget, {
|
||||
preserveProgress: true,
|
||||
moveSource: "engine",
|
||||
recoveryRehome: true,
|
||||
@@ -12258,7 +12386,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
fresh.column === "in-review"
|
||||
/* The log line names WHICH route ran, so it asks the same resolved review membership the
|
||||
router did — a literal here describes a renamed-board recovery as the wrong kind. */
|
||||
freshColumns.review.has(fresh.column)
|
||||
? "Auto-recovered: in-review pause-abort park cleared — preserved for normal review progression"
|
||||
: "Auto-recovered: pause-abort park cleared — requeued for normal scheduling",
|
||||
);
|
||||
@@ -13012,6 +13142,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
...await resolveProjectColumnsForRoles(this.store, REVIEW_ROLES),
|
||||
]);
|
||||
|
||||
/* FNXC:WorkflowResolvedColumns 2026-07-31-23:45: the parked pair handed to
|
||||
`evaluateParkedAgentTaskLink`; legacy-seeded, so unconverted boards are unchanged. */
|
||||
const agentParkedColumns = await resolveProjectColumnsForRoles(this.store, ["hold", "intake"]);
|
||||
for (const agent of runningAgents) {
|
||||
if (isEphemeralAgent(agent) || !agent.taskId) {
|
||||
continue;
|
||||
@@ -13027,7 +13160,21 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const activeRun = await agentStore.getActiveHeartbeatRun(agent.id);
|
||||
const proof = evaluateParkedAgentTaskLink({
|
||||
agent,
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:45:
|
||||
The synthetic stand-in for a MISSING task stays the legacy id ON PURPOSE, and is now correct
|
||||
rather than merely unconverted: `parkedColumns` below is legacy-seeded, so `todo` is a member
|
||||
on every board. A deleted row also has no workflow selection, so a "renamed" placeholder
|
||||
would be a guess about a task that no longer exists.
|
||||
*/
|
||||
linkedTask: linkedTask ?? { column: "todo" } as Pick<Task, "column">,
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:45:
|
||||
Previously OMITTED, so this call fell back to `LEGACY_PARKED_COLUMNS` (`todo`/`triage`) and a
|
||||
live agent linked to a card resting in a RENAMED hold lane read as not-parked — the safeguard
|
||||
that preserves its task link never applied, and the link was dropped.
|
||||
*/
|
||||
parkedColumns: [...agentParkedColumns],
|
||||
activeRun,
|
||||
hasActiveAgentExecution: this.options.hasActiveAgentExecution,
|
||||
now,
|
||||
@@ -14248,6 +14395,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
this.options.evictStaleTriageProcessing?.();
|
||||
|
||||
const tasks = await this.store.listTasks({ slim: true, includeArchived: false });
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-31-23:40:
|
||||
Resolved WAITING membership for the two peer-progress counts below. The question is "are OTHER
|
||||
queued cards moving while this refinement card starves", so it must include every lane a card
|
||||
can wait in — hold and intake. Keyed on `todo` alone, a renamed board counted zero peers and
|
||||
the starvation escalation never fired.
|
||||
*/
|
||||
const starvedWaitingColumns = await resolveProjectColumnsForRoles(this.store, ["hold", "intake"]);
|
||||
const planningIds = this.options.getPlanningTaskIds?.() ?? new Set<string>();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -14271,7 +14426,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
|
||||
const peerProgressCount = tasks.filter((peer) =>
|
||||
peer.id !== task.id &&
|
||||
peer.column === "todo" &&
|
||||
starvedWaitingColumns.has(peer.column) &&
|
||||
peer.sourceType !== "task_refine" &&
|
||||
new Date(peer.updatedAt).getTime() > createdAtMs,
|
||||
).length;
|
||||
@@ -14292,7 +14447,7 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
|
||||
const createdAtMs = new Date(task.createdAt).getTime();
|
||||
const peerProgressCount = tasks.filter((peer) =>
|
||||
peer.id !== task.id &&
|
||||
peer.column === "todo" &&
|
||||
starvedWaitingColumns.has(peer.column) &&
|
||||
peer.sourceType !== "task_refine" &&
|
||||
new Date(peer.updatedAt).getTime() > createdAtMs,
|
||||
).length;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"generatedFrom": "node scripts/lifecycle-column-census.mjs --strict --update-baseline",
|
||||
"byFile": {
|
||||
"packages/engine/src/self-healing.ts": 22,
|
||||
"packages/engine/src/self-healing.ts": 6,
|
||||
"packages/engine/src/executor.ts": 4,
|
||||
"packages/dashboard/app/utils/taskRevert.ts": 2,
|
||||
"packages/engine/src/auto-merge-finalization.ts": 2,
|
||||
|
||||
Reference in New Issue
Block a user