fix: uncap workflow principals and auto-resume stranded continuations

Two independent wedges kept cards silently stuck on the board.

1. Workflow principals were capped. `WorkflowAgentCapacity.acquire` enforced
   `settings.maxConcurrent` as a project session budget plus an optional
   per-agent `maxWorkflowSessions`, and `routeWorkflowPrincipal`'s availability
   test applied the same per-agent ceiling. The workflow roles stand in for
   STAGES, not workers, and there is typically one agent per role - so the cap
   serialized the entire board behind a single Workflow Executor regardless of
   maxConcurrent/maxWorktrees. Admission now always succeeds; the lease survives
   as bookkeeping (it is what activeSessions counts and what the renewal timer
   keeps warm). `maxProjectSessions` is removed from the input rather than
   defaulted, so it cannot be reintroduced without deleting the contract, and
   the agent-capacity re-route loops in triage and graph admission are deleted
   with the refusal they existed to work around.

2. Continuations that stop in `running` or `held` were never re-polled. The
   scheduler's due-poll takes only `runnable`/`retrying`; a row claimed through
   a path that leaves `leaseExpiresAt` NULL keeps `state: "running"` forever
   after its process dies, and `acquireWorkflowWorkItemLease` can only re-take a
   `held` row whose blockedReason matches workflow-principal-%. Observed live:
   seven cards `running` behind leases from a process that exited ~9h earlier,
   two `held` with a NULL blockedReason for 46h, none emitting a single
   run-audit row while stranded. A further 33 active-state rows belonged to
   archived+soft-deleted tasks (the FK cascade only fires on hard delete).

   New sweep `reconcileStrandedWorkflowContinuations` (startup + periodic)
   re-queues both stranded shapes and retires dead tasks' rows, gated by the
   canonical liveness triple, a 10-minute grace matching the capacity lease
   duration, and a compare-and-set on the scanned state so a real claim wins.
   The decision is the pure `evaluateStrandedContinuationReclaim`, shared with
   its tests so coverage cannot drift from behavior - the drift that let the
   FN-8923 sweep ship covering one ninth of this problem.

Verified: pnpm lint, engine typecheck, pnpm test:gate (606 tests), verify:fast,
and the new suite under mutation (removing either guard fails 3 cases). The two
pre-existing failures in self-healing-orphaned-pending-step-results.test.ts
reproduce identically at HEAD without these changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-11 02:02:51 -07:00
parent 62979d8796
commit 06ea444339
10 changed files with 561 additions and 101 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Remove workflow principal session caps and auto-resume continuations stranded in running or held.
category: fix
dev: `WorkflowAgentCapacity.acquire` drops `maxProjectSessions`/`maxWorkflowSessions` (leases become bookkeeping only) and `routeWorkflowPrincipal`'s availability test is now eligibility-only, so the capacity re-route loops in `triage.ts` and `workflow-principal-before-node.ts` are deleted. New self-healing sweep `reconcileStrandedWorkflowContinuations` (startup + periodic) re-queues `running` rows with a dead/absent lease and `held` rows the claim predicate cannot re-take, and retires active-state rows belonging to deleted/archived tasks; decision logic is the pure `evaluateStrandedContinuationReclaim`. New run-audit types: `workflowWorkItem:reconcile-stranded-requeued`, `workflowWorkItem:reconcile-stranded-retired`.

View File

@@ -113,11 +113,17 @@ describe("executor routes workflow stages through durable principals", () => {
expect(agentStore.listAgents).toHaveBeenCalledWith({ includeEphemeral: true });
expect(store.upsertWorkflowWorkItem).toHaveBeenCalled();
/*
FNXC:WorkflowAgentRouting 2026-08-11-09:12:
`maxProjectSessions` is gone from this call by design — workflow principals have no execution cap,
so admission passes no limit at all (see `WorkflowAgentCapacity.acquire`). Asserted as an ABSENCE,
not merely dropped from the shape, so silently reintroducing the cap fails here.
*/
expect(acquire).toHaveBeenCalledWith(expect.objectContaining({
projectId: "project-fn-8821",
agent: expect.objectContaining({ id: "workflow-executor" }),
maxProjectSessions: 2,
}));
expect(acquire.mock.calls[0]?.[0]).not.toHaveProperty("maxProjectSessions");
expect(release).toHaveBeenCalledOnce();
expect((TaskExecutor as unknown as { processWideGraphRouting: Set<string> }).processWideGraphRouting).not.toContain(live.id);
expect(store.upsertWorkflowWorkItem).toHaveBeenCalledWith(expect.objectContaining({

View File

@@ -0,0 +1,194 @@
import { describe, expect, it, vi } from "vitest";
import type { Settings, Task, TaskStore, WorkflowWorkItem } from "@fusion/core";
const { recordRunAuditEventMock, resolveTaskLifecycleColumnsMock } = vi.hoisted(() => ({
recordRunAuditEventMock: vi.fn(async () => undefined),
resolveTaskLifecycleColumnsMock: vi.fn(),
}));
vi.mock("@fusion/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@fusion/core")>()),
resolveTaskLifecycleColumns: resolveTaskLifecycleColumnsMock,
}));
vi.mock("../util/run-audit.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../util/run-audit.js")>()),
createRunAuditor: vi.fn(() => ({ database: recordRunAuditEventMock })),
}));
import { SelfHealingManager } from "../self-healing.js";
import { evaluateStrandedContinuationReclaim } from "../workflows/stranded-continuation-reclaim.js";
/*
FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
The observed incident: the scheduler's due-poll takes only `runnable`/`retrying`, so a continuation that
stops in `running` or `held` is never re-examined by anything. Nine live cards on the Fusion board were
stranded that way — seven `running` behind leases whose process exited ~9h earlier (NULL `leaseExpiresAt`,
so they never aged out) and two `held` with a NULL `blockedReason` for 46h (the claim predicate only
re-takes `workflow-principal-%`/`workflow-named-principal-%`/`workflow-role-pool-%` holds). A further 33
rows in active states belonged to tasks that were archived AND soft-deleted, the oldest a month prior:
the FK cascade only fires on a hard delete.
Surface enumeration — the invariant is asserted across every state the wedge can wear, not just the
reported one: `running` with a NULL lease, `running` with an EXPIRED lease, `held` with a NULL reason,
`held` with a principal-routing reason, a retired task's row in each active state, and the negative
cases (live session, unexpired lease, operator pause, manual-hold kind, engine pause, too-fresh).
*/
const stale = (ms: number) => new Date(Date.now() - ms).toISOString();
const GRACE_EXCEEDED = 20 * 60_000;
function item(overrides: Partial<WorkflowWorkItem> = {}): WorkflowWorkItem {
return {
id: "wi-1", runId: "run-1", taskId: "FN-8932", nodeId: "plan-review", kind: "task",
state: "running", attempt: 0, retryAfter: null,
leaseOwner: "executor:FN-8932", leaseExpiresAt: null, lastError: null, blockedReason: null,
createdAt: stale(GRACE_EXCEEDED), updatedAt: stale(GRACE_EXCEEDED),
...overrides,
} as unknown as WorkflowWorkItem;
}
function harness(
items: WorkflowWorkItem[] = [item()],
taskOverrides: Partial<Task> = {},
settings: Partial<Settings> = {},
) {
const task = {
id: "FN-8932", title: "Memory layer 4a", description: "", column: "todo",
dependencies: [], steps: [], currentStep: 0, log: [],
createdAt: stale(GRACE_EXCEEDED), updatedAt: stale(GRACE_EXCEEDED),
...taskOverrides,
} as unknown as Task;
const transitions: Array<{ id: string; state: string; patch: Record<string, unknown> }> = [];
const logged: string[] = [];
const store = {
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false, ...settings } as Settings)),
listDueWorkflowWorkItems: vi.fn(async () => items),
getTask: vi.fn(async (id: string) => (id === task.id ? task : undefined)),
transitionWorkflowWorkItem: vi.fn(async (id: string, state: string, patch: Record<string, unknown>) => {
transitions.push({ id, state, patch });
return { ...items.find((entry) => entry.id === id)!, state };
}),
logEntry: vi.fn(async (_id: string, message: string) => { logged.push(message); }),
getRootDir: vi.fn(() => "/repo"),
getTasksDir: vi.fn(() => ""),
} as unknown as TaskStore;
resolveTaskLifecycleColumnsMock.mockResolvedValue({ complete: "done", archived: "archived" });
return { task, store, transitions, logged, manager: new SelfHealingManager(store, { rootDir: "/repo" }) };
}
describe("reconcileStrandedWorkflowContinuations", () => {
it("re-queues a running continuation whose lease has no owner left", async () => {
recordRunAuditEventMock.mockClear();
const { manager, transitions, logged } = harness();
await expect(manager.reconcileStrandedWorkflowContinuations()).resolves.toBe(1);
expect(transitions).toEqual([{
id: "wi-1",
state: "runnable",
// Clearing BOTH is what makes the row claimable: a stale leaseOwner reads as owned, and a
// surviving blockedReason keeps the claim predicate out.
patch: expect.objectContaining({ leaseOwner: null, leaseExpiresAt: null, blockedReason: null, expectedState: "running" }),
}]);
expect(logged.join(" ")).toContain("re-queued");
expect(recordRunAuditEventMock).toHaveBeenCalledWith(expect.objectContaining({
type: "workflowWorkItem:reconcile-stranded-requeued",
metadata: expect.objectContaining({ taskId: "FN-8932", priorState: "running", reason: "dead-lease" }),
}));
});
/* Every stranded shape observed in the incident, plus the expired-lease variant. */
it("re-queues each stranded shape a dispatcher would never re-poll", async () => {
for (const [label, overrides] of [
["running, null lease expiry", { state: "running", leaseExpiresAt: null }],
["running, expired lease", { state: "running", leaseExpiresAt: stale(GRACE_EXCEEDED) }],
["held, null blocked reason", { state: "held", leaseOwner: null, blockedReason: null }],
["held, principal routing reason", { state: "held", leaseOwner: null, blockedReason: "workflow-principal-role-pool-exhausted:executor" }],
] as Array<[string, Partial<WorkflowWorkItem>]>) {
const { manager, transitions } = harness([item(overrides)]);
await expect(manager.reconcileStrandedWorkflowContinuations(), label).resolves.toBe(1);
expect(transitions[0]?.state, label).toBe("runnable");
}
});
it("retires rows whose task can never run them again", async () => {
for (const taskOverrides of [
{ column: "archived", deletedAt: stale(1) },
{ column: "archived" },
{ column: "done" },
] as Array<Partial<Task>>) {
for (const state of ["running", "held", "runnable", "retrying"] as const) {
const { manager, transitions } = harness([item({ state })], taskOverrides);
await expect(manager.reconcileStrandedWorkflowContinuations()).resolves.toBe(1);
expect(transitions[0]?.state).toBe("cancelled");
}
}
});
it("retires a row whose task no longer resolves at all", async () => {
const { manager, transitions } = harness([item({ taskId: "FN-DELETED" })]);
await expect(manager.reconcileStrandedWorkflowContinuations()).resolves.toBe(1);
expect(transitions[0]?.state).toBe("cancelled");
});
/*
The false positive that would be worse than the bug: re-queueing a row a live session still owns
double-dispatches the task. Each guard is asserted separately so none can be dropped silently.
*/
it("never touches a row that is live, owned, paused, operator-held, or too fresh", async () => {
const cases: Array<[string, Parameters<typeof harness>]> = [
["unexpired lease", [[item({ leaseExpiresAt: new Date(Date.now() + 600_000).toISOString() })], {}, {}]],
["too fresh", [[item({ updatedAt: new Date().toISOString() })], {}, {}]],
["manual-hold kind", [[item({ state: "held", kind: "manual-hold" })], {}, {}]],
["operator paused", [[item()], { userPaused: true } as Partial<Task>, {}]],
["task paused", [[item()], { paused: true } as Partial<Task>, {}]],
["engine paused", [[item()], {}, { enginePaused: true }]],
["global pause", [[item()], {}, { globalPause: true }]],
["scheduler-owned runnable", [[item({ state: "runnable", leaseOwner: null })], {}, {}]],
];
for (const [label, args] of cases) {
const { manager, transitions } = harness(...args);
await expect(manager.reconcileStrandedWorkflowContinuations(), label).resolves.toBe(0);
expect(transitions, label).toEqual([]);
}
});
/*
The write is compare-and-set fenced on the state the scan observed. A dispatcher that legitimately
claimed the row in between must win, and the sweep must not count or log a repair it did not make.
*/
it("does not count a repair when the compare-and-set loses to a real claim", async () => {
const { manager, store, logged } = harness();
(store.transitionWorkflowWorkItem as ReturnType<typeof vi.fn>).mockResolvedValue({ ...item(), state: "running" });
await expect(manager.reconcileStrandedWorkflowContinuations()).resolves.toBe(0);
expect(logged).toEqual([]);
});
});
describe("evaluateStrandedContinuationReclaim", () => {
const base = {
item: { state: "running", kind: "task", leaseExpiresAt: null, blockedReason: null },
taskTerminal: false, taskMissing: false, taskPaused: false,
live: false, enginePaused: false, stalenessMs: GRACE_EXCEEDED, graceMs: 600_000, now: Date.now(),
} as Parameters<typeof evaluateStrandedContinuationReclaim>[0];
it("orders engine pause above every other disposition", () => {
// Even a retirable row waits: a paused engine must make no autonomous writes at all.
expect(evaluateStrandedContinuationReclaim({ ...base, enginePaused: true, taskMissing: true }))
.toEqual({ action: "none", reason: "engine-paused" });
});
it("retires a dead task's row regardless of pause or liveness flags", () => {
// Retirement sits ABOVE the pause/liveness guards on purpose — a deleted task has no operator
// decision left to respect, and that ordering is why the month-old residue accumulated.
expect(evaluateStrandedContinuationReclaim({ ...base, taskTerminal: true, taskPaused: true, live: true }))
.toEqual({ action: "retire", reason: "task-terminal" });
});
it("treats a future lease expiry as proof of a live claim even past the grace window", () => {
expect(evaluateStrandedContinuationReclaim({
...base,
item: { ...base.item, leaseExpiresAt: new Date(base.now + 60_000).toISOString() },
stalenessMs: 24 * 3_600_000,
})).toEqual({ action: "none", reason: "lease-active" });
});
});

View File

@@ -4,13 +4,35 @@ import { WorkflowAgentCapacity } from "../agents/workflow-agent-capacity.js";
const agent = (id: string, maxWorkflowSessions?: number) => ({ id, runtimeConfig: { maxWorkflowSessions } }) as any;
describe("WorkflowAgentCapacity", () => {
it("keeps workflow and heartbeat limits independent while enforcing project then agent limits", async () => {
/*
FNXC:WorkflowAgentRouting 2026-08-11-09:12:
Replaces the former "enforces project then agent limits" case. Workflow principals have NO execution
cap: one Workflow Executor must be able to hold many concurrent sessions, because a per-principal
ceiling serialized the whole board (there is typically exactly one agent per role) and its refusal
became a durable `held` row no dispatcher re-polled.
*/
it("never refuses a workflow principal, however many sessions it already holds", async () => {
const capacity = new WorkflowAgentCapacity();
// `maxWorkflowSessions: 1` is deliberately set and must be ignored — it is the exact config that
// used to serialize a single-executor board.
const constrained = agent("executor", 1);
expect(await capacity.acquire({ projectId: "project-a", agent: constrained, attemptId: "one", maxProjectSessions: 2 })).toMatchObject({ status: "acquired" });
expect(await capacity.acquire({ projectId: "project-a", agent: constrained, attemptId: "two", maxProjectSessions: 2 })).toEqual({ status: "held", reason: "agent-capacity" });
expect(await capacity.acquire({ projectId: "project-a", agent: agent("reviewer"), attemptId: "three", maxProjectSessions: 2 })).toMatchObject({ status: "acquired" });
expect(await capacity.acquire({ projectId: "project-a", agent: agent("merger"), attemptId: "four", maxProjectSessions: 2 })).toEqual({ status: "held", reason: "project-capacity" });
for (const attemptId of ["one", "two", "three", "four", "five"]) {
expect(await capacity.acquire({ projectId: "project-a", agent: constrained, attemptId })).toMatchObject({ status: "acquired" });
}
expect(capacity.activeSessions("executor", "project-a")).toBe(5);
// A second role on the same project is likewise uncapped.
expect(await capacity.acquire({ projectId: "project-a", agent: agent("reviewer"), attemptId: "six" })).toMatchObject({ status: "acquired" });
});
it("passes no session limits to the durable store, so a cap cannot be reintroduced by the caller", async () => {
const calls: Array<Record<string, unknown>> = [];
const capacity = new WorkflowAgentCapacity({
acquireWorkflowSessionCapacity: async (input) => { calls.push(input); return "acquired"; },
releaseWorkflowSessionCapacity: async () => undefined,
});
await capacity.acquire({ projectId: "project-a", agent: agent("executor", 1), attemptId: "uncapped" });
expect(calls[0]).not.toHaveProperty("maxProjectSessions");
expect(calls[0]).not.toHaveProperty("maxAgentSessions");
});
it("isolates matching agent and attempt IDs across projects", async () => {
@@ -25,7 +47,7 @@ describe("WorkflowAgentCapacity", () => {
it("allows a fenced attempt to reacquire and releases exactly once", async () => {
const capacity = new WorkflowAgentCapacity();
const input = { projectId: "project-a", agent: agent("executor", 1), attemptId: "attempt", maxProjectSessions: 1 };
const input = { projectId: "project-a", agent: agent("executor", 1), attemptId: "attempt" };
const first = await capacity.acquire(input);
expect(await capacity.acquire(input)).toEqual(first);
expect(capacity.activeSessions("executor")).toBe(1);

View File

@@ -42,33 +42,48 @@ export class WorkflowAgentCapacity {
return `${projectId}\u0000${attemptId}`;
}
/**
* FNXC:WorkflowAgentRouting 2026-08-11-09:12:
* Workflow principals have NO execution cap. Admission always succeeds; the lease is taken purely as
* bookkeeping — it is what `activeSessions` counts and what the renewal timer keeps warm — never as a
* gate.
*
* Why the caps went away: the workflow roles (triage, executor, reviewer, merger) stand in for STAGES,
* not for workers, and there are typically one of each. Capping their concurrent sessions therefore
* capped the whole board — a project with a single Workflow Executor serialized every implementation
* task behind one session regardless of what `maxConcurrent`/`maxWorktrees` allowed. The refusal was
* also invisible: it surfaced as a durable `held` row reading
* `workflow-principal-agent-capacity:executor`, and until the FN reclaim sweep shipped alongside this
* change, nothing re-polled a `held` row at all. Task parallelism is already bounded where it belongs
* (worktree + concurrency admission on the task itself); a second bound at the principal was pure
* serialization with no safety value.
*
* `maxProjectSessions` is deliberately REMOVED from the input rather than defaulted to undefined, so a
* caller cannot silently reintroduce the cap without first deleting this contract.
* `runtimeConfig.maxWorkflowSessions` is likewise no longer consulted, here or in
* `routeWorkflowPrincipal`'s availability test.
*/
public async acquire(input: {
projectId: string;
agent: Pick<Agent, "id" | "runtimeConfig">;
attemptId: string;
maxProjectSessions?: number;
}): Promise<WorkflowAgentCapacityResult> {
const attemptKey = this.attemptKey(input.projectId, input.attemptId);
const existing = this.leases.get(attemptKey);
if (existing) return { status: "acquired", lease: existing };
const agentLimit = input.agent.runtimeConfig?.maxWorkflowSessions;
const maxAgentSessions = typeof agentLimit === "number" && Number.isFinite(agentLimit)
? agentLimit
: undefined;
if (this.leaseStore) {
/*
FNXC:WorkflowAgentRouting 2026-08-11-09:12:
No limits are passed, so the durable store records the lease and returns "acquired". The store
KEEPS its limit parameters: they remain the correct cross-process gate for a caller that genuinely
needs one, and the reclaim of expired rows there is still what frees leases after a crash.
*/
const outcome = await this.leaseStore.acquireWorkflowSessionCapacity({
agentId: input.agent.id,
attemptId: input.attemptId,
maxProjectSessions: input.maxProjectSessions,
maxAgentSessions,
leaseDurationMs: WorkflowAgentCapacity.LEASE_DURATION_MS,
});
if (outcome !== "acquired") return { status: "held", reason: outcome };
} else {
// Unit-test/local fallback retains deterministic semantics without pretending to coordinate processes.
const local = [...this.leases.values()].filter((lease) => lease.projectId === input.projectId);
if (input.maxProjectSessions !== undefined && local.length >= input.maxProjectSessions) return { status: "held", reason: "project-capacity" };
if (maxAgentSessions !== undefined && local.filter((lease) => lease.agentId === input.agent.id).length >= maxAgentSessions) return { status: "held", reason: "agent-capacity" };
}
const lease = { projectId: input.projectId, agentId: input.agent.id, attemptId: input.attemptId };
this.leases.set(attemptKey, lease);

View File

@@ -154,13 +154,13 @@ export function validateFencedWorkflowPrincipal(input: {
})) {
return staleFence;
}
return available(agent, input.activeSessions ?? new Map())
return available(agent)
? { status: "routed", route: { agent, role: input.role, authority: input.authority } }
: { status: "held", role: input.role, reason: "named-principal-unavailable" };
}
/** A named principal is never silently replaced once a precedence branch names it. */
function available(agent: Agent | undefined, activeSessions: ReadonlyMap<string, number>): agent is Agent {
function available(agent: Agent | undefined): agent is Agent {
/*
* FNXC:WorkflowAgentRouting 2026-08-07-03:38:
* Workflow-stage routing may only select durable operator-visible principals.
@@ -172,8 +172,16 @@ function available(agent: Agent | undefined, activeSessions: ReadonlyMap<string,
// (isWorkflowPrincipalEligible) so "what provisioning must produce" and "what routing accepts"
// cannot drift apart again — that drift is what left every built-in owner unroutable.
if (!agent || isEphemeralAgent(agent) || !isWorkflowPrincipalEligible(agent)) return false;
const max = agent.runtimeConfig?.maxWorkflowSessions;
return typeof max !== "number" || activeSessions.get(agent.id) === undefined || activeSessions.get(agent.id)! < max;
/*
FNXC:WorkflowAgentRouting 2026-08-11-09:12:
Availability is now a pure ELIGIBILITY test — is this a durable principal that may run this role — with
no session-count ceiling. `runtimeConfig.maxWorkflowSessions` used to gate here; a role agent at its
ceiling reported `role-pool-exhausted`/`named-principal-unavailable` and the node became a durable
`held` row that no dispatcher re-polled. With one agent per role that ceiling serialized the entire
board. See `WorkflowAgentCapacity.acquire` for the full rationale — the two must stay consistent, since
a cap in either place reproduces the same wedge on its own.
*/
return true;
}
/**
@@ -225,7 +233,7 @@ export function routeWorkflowPrincipal(input: {
if (!agent || !hasWorkflowRoleCapability(agent, role, { allowEngineerAsExecutor: authority === "task-assignee" })) {
return undefined;
}
return available(agent, activeSessions)
return available(agent)
? { status: "routed", route: { agent, role, authority } }
: { status: "held", role, reason: "named-principal-unavailable" };
};
@@ -247,7 +255,7 @@ export function routeWorkflowPrincipal(input: {
if (bound) return bound;
const pool = input.agents
.filter((agent) => !input.excludedPoolAgentIds?.has(agent.id)
&& hasWorkflowRoleCapability(agent, role) && available(agent, activeSessions))
&& hasWorkflowRoleCapability(agent, role) && available(agent))
.sort((left, right) => (activeSessions.get(left.id) ?? 0) - (activeSessions.get(right.id) ?? 0)
|| left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
const agent = pool[0];

View File

@@ -123,8 +123,6 @@ let routed = hasFencedPrincipal
agents,
activeSessions,
});
/** Cleared when a stale fence is discarded, so the pool-capacity retry below is no longer fenced off. */
let fenceStillGoverns = Boolean(hasFencedPrincipal);
/*
* FNXC:WorkflowAgentRouting 2026-08-10-07:50:
* A fence that no longer describes reality is not a wait — the principal is gone, lost the role, or had its
@@ -144,8 +142,6 @@ if (routed.status === "held" && routed.staleFence) {
agents,
activeSessions,
});
// The fence is discarded, so a fresh role-pool route may take the cross-engine capacity retry below.
fenceStillGoverns = false;
}
if (routed.status === "unclassified") return undefined;
/*
@@ -268,49 +264,24 @@ if (routed.status === "held") {
*/
const attemptId = `${deps.resolvedRunId ?? `${nodeTask.id}:workflow`}:${nodeInstanceId}`;
/*
* FNXC:WorkflowAgentRouting 2026-08-07-05:29:
* Workflow-stage admission consumes the project workflow budget, while
* an agent's heartbeat retains its separate maxConcurrentRuns budget.
* Passing the project limit here closes the direct-graph path, which
* otherwise enforced only optional per-agent limits.
* FNXC:WorkflowAgentRouting 2026-08-11-09:12:
* Workflow-stage admission no longer consumes a project workflow budget — workflow principals have NO
* execution cap (see `WorkflowAgentCapacity.acquire` for why: with one durable agent per role, capping
* principal sessions capped the entire board, and the refusal became a durable `held` row nothing
* re-polled). The lease is still taken, because it is what `activeSessions` counts and what the renewal
* timer keeps warm; it just cannot refuse. An agent's heartbeat keeps its separate `maxConcurrentRuns`
* budget, which was always a different thing.
*
* The `agent-capacity` re-route loop is DELETED with the cap that motivated it: it existed only to pick
* a different pool member after a durable refusal, and there is no longer a refusal to react to. The
* `held` branch below survives as a fail-closed guard for a store-level refusal, and still records the
* durable held item so the state is inspectable rather than terminalized.
*/
let capacity = await deps.workflowAgentCapacity.acquire({
const capacity = await deps.workflowAgentCapacity.acquire({
projectId: deps.options.agentStore.workflowProjectId ?? deps.store.getRootDir(),
agent: routed.route.agent,
attemptId,
maxProjectSessions: deps.settings.maxConcurrent,
});
/*
* FNXC:WorkflowAgentRouting 2026-08-07-07:32:
* A role-pool snapshot is process-local, while admission is durable
* across engines. If another engine filled the selected agent between
* selection and the atomic acquire, try the next eligible pool member.
* Fenced and named principals never take this fallback.
*/
if (capacity.status === "held" && capacity.reason === "agent-capacity"
&& routed.route.authority === "role-pool" && !fenceStillGoverns) {
const excludedPoolAgentIds = new Set<string>();
while (capacity.status === "held" && capacity.reason === "agent-capacity"
&& routed.route.authority === "role-pool") {
excludedPoolAgentIds.add(routed.route.agent.id);
const retryRoute = routeWorkflowPrincipal({
task: nodeTask,
ir: deps.columnAgentIr,
node,
agents,
activeSessions,
excludedPoolAgentIds,
});
if (retryRoute.status !== "routed" || retryRoute.route.authority !== "role-pool") break;
routed = retryRoute;
capacity = await deps.workflowAgentCapacity.acquire({
projectId: deps.options.agentStore.workflowProjectId ?? deps.store.getRootDir(),
agent: routed.route.agent,
attemptId,
maxProjectSessions: deps.settings.maxConcurrent,
});
}
}
if (capacity.status === "held") {
const reason = `workflow-principal-${capacity.reason}:${routed.route.role}`;
await holdDirectPrincipalWorkItem(reason, routed.route.agent.id, routed.route.authority);

View File

@@ -83,6 +83,7 @@ import { isTaskStillInPlanningStage } from "./execution/replan-target.js";
import { classifyPersistedPlanHandoff, LEGACY_NULL_PLAN_HANDOFF_STALE_MS } from "./planning-handoff-recovery.js";
import { getPromptPath } from "./execution/spec-staleness.js";
import { evaluateStrandedHoldContinuation, seedPreReleasePlanReviewContinuation } from "./plan-review-continuation.js";
import { evaluateStrandedContinuationReclaim, RECLAIM_RETIRED_STATE } from "./workflows/stranded-continuation-reclaim.js";
/*
FNXC:Workspace 2026-06-22-14:10 (Phase D review G — cycle dissolved):
`isRepoLanded` is the CANONICAL per-repo landed predicate (Phase C, exported A6). It now lives in
@@ -1744,6 +1745,14 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// FNXC:PrincipalHeldPlanning 2026-08-10-08:20: a planning hold from principal routing has no other
// retry owner, so it must be re-queued before the steps below classify the card as simply idle.
{ name: "reconcile-principal-held-planning", fn: () => this.reconcilePrincipalHeldPlanningContinuations().then(() => undefined) },
/*
FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
Runs AFTER the two narrow continuation sweeps above and before any step that classifies a card as
idle. Startup is the highest-yield moment for it: a killed process is exactly what leaves a
`running` row with a lease that now has no owner, and until this sweep existed those rows survived
every subsequent restart untouched.
*/
{ name: "reconcile-stranded-workflow-continuations", fn: () => this.reconcileStrandedWorkflowContinuations().then(() => undefined) },
{ name: "no-progress-no-task-done", fn: () => this.recoverNoProgressNoTaskDoneFailures().then(() => undefined) },
{ name: "completed-tasks", fn: () => this.recoverCompletedTasks().then(() => undefined) },
{ name: "recover-stranded-completed-todo", fn: () => this.recoverStrandedCompletedTodoTasks().then(() => undefined) },
@@ -2858,6 +2867,9 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
// no benefit.
{ name: "reconcile-stranded-hold-continuations", fn: () => this.reconcileStrandedHoldContinuations() },
{ name: "reconcile-principal-held-planning", fn: () => this.reconcilePrincipalHeldPlanningContinuations() },
// FNXC:StrandedContinuationReclaim 2026-08-11-09:12: steady-state half of the startup sweep —
// a session can die mid-run without a restart, and the grace window keeps live work untouched.
{ name: "reconcile-stranded-workflow-continuations", fn: () => this.reconcileStrandedWorkflowContinuations() },
{ name: "recover-mergeable-review", fn: () => this.recoverMergeableReviewTasks() },
// FNXC:Workspace 2026-06-22-09:30 (Phase D U1) — workspace-mode reconcilers.
{ name: "reconcile-workspace-partial-lands", fn: () => this.reconcileWorkspacePartialLands() },
@@ -7704,6 +7716,135 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
}
}
/**
* FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
* The general reclaim for continuations no dispatcher will ever look at again. See
* `workflows/stranded-continuation-reclaim.ts` for the full mechanism; in short, the due-poll selects
* only `runnable`/`retrying`, so a row that stops in `running` (dead lease, NULL expiry) or `held`
* (reason the claim predicate cannot re-take) is silently terminal, and a soft-deleted task's rows are
* never cascaded away.
*
* This is the SUPERSET sweep that `reconcilePrincipalHeldPlanningContinuations` is the narrow special
* case of: that one restores a planning SIGNAL (`needs-replan`) so triage re-routes a held triage-role
* plan node, which is the correct repair for its shape and is deliberately left alone here. This sweep
* repairs the ROW, moving it back to `runnable` so the ordinary drain claims it on the next tick.
*
* Scan shape: `listDueWorkflowWorkItems` already filters `leaseExpiresAt IS NULL OR <= now`, so the
* one query returns exactly the rows that could be abandoned — no per-task fan-out, and no unbounded
* table walk. Every write is compare-and-set fenced on the state the scan observed, so a row a real
* dispatcher claimed between scan and write is left untouched rather than reset under a live session.
*
* @returns Count of rows actually re-queued or retired.
*/
async reconcileStrandedWorkflowContinuations(): Promise<number> {
try {
if (typeof this.store.listDueWorkflowWorkItems !== "function") return 0;
const settings = await this.store.getSettings();
const enginePaused = settings.globalPause === true || settings.enginePaused === true;
if (enginePaused) return 0;
/*
FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
Ten minutes matches `WorkflowAgentCapacity.LEASE_DURATION_MS`, the longest a healthy claim can go
without renewing its durable capacity row. Anything younger may be a live session mid-renewal.
*/
const graceMs = 10 * 60_000;
const now = Date.now();
const nowIso = new Date(now).toISOString();
const live = (taskId: string) => activeSessionRegistry.pathsForTask(taskId).some((path) => activeSessionRegistry.isPathActive(path))
|| executingTaskLock.has(taskId)
|| this.options.isTaskActive?.(taskId) === true;
const due = await this.store.listDueWorkflowWorkItems({
now: nowIso,
states: [...ACTIVE_WORKFLOW_WORK_ITEM_STATES],
});
let repaired = 0;
for (const item of due) {
try {
/*
FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
`getTask` must see soft-deleted and archived rows, because those are precisely the tasks whose
continuations need retiring. A reader that hides them reports `task-missing`, which retires the
row too — the same disposition, so the sweep stays correct either way.
*/
const task = await this.store.getTask(item.taskId);
const terminalColumns = await resolveTaskLifecycleColumns(this.store, item.taskId).catch(() => undefined);
const doneColumn = terminalColumns?.complete ?? "done";
const archivedColumn = terminalColumns?.archived ?? "archived";
const verdict = evaluateStrandedContinuationReclaim({
item,
taskMissing: !task,
taskTerminal: !!task && (
task.deletedAt != null
|| task.column === archivedColumn
|| task.column === doneColumn
|| task.column === "archived"
|| task.column === "done"
),
taskPaused: task?.userPaused === true || task?.paused === true,
live: live(item.taskId),
enginePaused,
stalenessMs: Math.max(0, now - new Date(item.updatedAt).getTime()),
graceMs,
now,
});
if (verdict.action === "none") continue;
const target = verdict.action === "retire" ? RECLAIM_RETIRED_STATE : "runnable";
const written = await this.store.transitionWorkflowWorkItem(item.id, target, {
/*
FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
Clearing the lease and the blocked reason is what makes the row claimable again — leaving
either behind reproduces the exact wedge this sweep exists to end (the claim predicate reads
`blockedReason`, and a stale `leaseOwner` makes the row look owned to every human reading it).
`lastError` is preserved: it is the only surviving evidence of why the row stopped.
*/
leaseOwner: null,
leaseExpiresAt: null,
blockedReason: null,
expectedState: item.state,
});
// CAS lost: another writer moved the row between the scan and this write. Leave it to them.
if (written.state !== target) continue;
repaired += 1;
if (verdict.action === "requeue") {
await this.store.logEntry(
item.taskId,
`[recovery] workflow continuation re-queued — ${item.nodeId} was stranded in '${item.state}' (${verdict.reason})`,
).catch(() => undefined);
}
await createRunAuditor(this.store, {
runId: generateSyntheticRunId("reconcile-stranded-continuation", item.taskId),
agentId: "self-healing",
taskId: item.taskId,
taskLineageId: task?.lineageId,
phase: "reconcile-stranded-continuation",
}).database({
type: (verdict.action === "retire"
? "workflowWorkItem:reconcile-stranded-retired"
: "workflowWorkItem:reconcile-stranded-requeued") as DatabaseMutationType,
target: item.id,
/* Ids/counts/outcomes only — never `lastError` prose or node config. */
metadata: {
taskId: item.taskId,
workItemId: item.id,
nodeId: item.nodeId,
kind: item.kind,
priorState: item.state,
reason: verdict.reason,
stalenessMs: Math.max(0, now - new Date(item.updatedAt).getTime()),
},
});
} catch (error) {
log.warn(`reconcileStrandedWorkflowContinuations: failed for ${item.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (repaired > 0) log.log(`Reclaimed ${repaired} stranded workflow continuation(s)`);
return repaired;
} catch (error) {
log.warn(`reconcileStrandedWorkflowContinuations failed: ${error instanceof Error ? error.message : String(error)}`);
return 0;
}
}
/*
FNXC:OrphanedPendingSteps 2026-07-22-16:20 (FN-8492 incident):
Consumer of `resolveOrphanedPendingStepResults` — the U9b helper shipped with NO caller

View File

@@ -2631,7 +2631,9 @@ export class TriageProcessor {
if (!Array.isArray(agents)) {
assignedAgent = assignedAgent ?? null;
} else {
let routed = routeWorkflowPrincipal({
// FNXC:WorkflowAgentRouting 2026-08-11-09:12: `const` since the capacity re-route loop that
// used to reassign this is gone with the principal execution cap.
const routed = routeWorkflowPrincipal({
task: currentTask,
ir: planningIr,
node: planningNode,
@@ -2679,43 +2681,21 @@ export class TriageProcessor {
triageRunContext.agentId = assignedAgent.id;
workflowCapacityAttemptId = `${triageRunContext.runId}:${planningNode.id}`;
workflowCapacityProjectId = this.options.agentStore.workflowProjectId ?? this.rootDir;
let capacity = await this.workflowAgentCapacity.acquire({
/*
FNXC:WorkflowAgentRouting 2026-08-11-09:12:
Capacity admission no longer refuses (see `WorkflowAgentCapacity.acquire`): workflow
principals have no execution cap, so the lease is bookkeeping and the acquire always
succeeds. The `agent-capacity` retry loop that used to re-route to another pool member on
refusal is DELETED with the cap it existed to work around — with no refusal there is no
contender to exclude. The `held` branch is kept as a fail-closed guard for a store-level
refusal (a future caller that does pass limits, or a durable-store error path); it parks the
card on `needs-replan`, which triage rediscovers, rather than dropping it.
*/
const capacity = await this.workflowAgentCapacity.acquire({
projectId: workflowCapacityProjectId,
agent: assignedAgent,
attemptId: workflowCapacityAttemptId,
maxProjectSessions: settings.maxConcurrent,
});
/*
FNXC:WorkflowAgentRouting 2026-08-07-07:32:
Role-pool selection is optimistic across engine processes. Retry a
different pool member only after durable agent-capacity rejects this
contender; task owners and other named principals remain fail-closed.
*/
if (capacity.status === "held" && capacity.reason === "agent-capacity"
&& routed.route.authority === "role-pool") {
const excludedPoolAgentIds = new Set<string>();
while (capacity.status === "held" && capacity.reason === "agent-capacity"
&& routed.route.authority === "role-pool") {
excludedPoolAgentIds.add(routed.route.agent.id);
const retryRoute = routeWorkflowPrincipal({
task: currentTask,
ir: planningIr,
node: planningNode,
agents,
excludedPoolAgentIds,
});
if (retryRoute.status !== "routed" || retryRoute.route.authority !== "role-pool") break;
routed = retryRoute;
assignedAgent = routed.route.agent;
triageRunContext.agentId = assignedAgent.id;
capacity = await this.workflowAgentCapacity.acquire({
projectId: workflowCapacityProjectId,
agent: assignedAgent,
attemptId: workflowCapacityAttemptId,
maxProjectSessions: settings.maxConcurrent,
});
}
}
if (capacity.status === "held") {
await this.store.logEntry(task.id, `Planning held: workflow-principal-${capacity.reason}:triage`);
await this.updatePlanningStateIfStillCurrent(task, { status: "needs-replan" });

View File

@@ -0,0 +1,116 @@
import { ACTIVE_WORKFLOW_WORK_ITEM_STATES, type WorkflowWorkItem } from "@fusion/core";
/*
FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
The scheduler's due-poll (`in-process-runtime.ts` -> `drainDuePlanningContinuations`) selects ONLY
`runnable`/`retrying`. A continuation that stops in `running` or `held` is therefore never looked at
again by any dispatcher, and the two ways it can stop there are both ordinary:
1. `running` with a dead session. `acquireWorkflowWorkItemLease` is the only writer that sets
`leaseExpiresAt`; the upsert/transition paths that install a fence leave it NULL. So a row claimed
through those paths whose process then dies keeps `state: "running"` with a `leaseOwner` and NO
expiry — permanently indistinguishable from live work to every reader that filters on state alone.
2. `held` with a blocked reason nothing retries. `acquireWorkflowWorkItemLease` can only re-take a
`held` row whose `blockedReason` matches `workflow-principal-%`, `workflow-named-principal-%`, or
`workflow-role-pool-%`; a NULL or non-matching reason never becomes claimable again.
Observed on the Fusion board on 2026-08-11: seven cards (FN-8932/8950/8953/8954/8956/8957/8958) sat in
`running` behind leases from a process that exited ~9h earlier, and FN-8901/FN-8902 sat `held` with a
NULL `blockedReason` for 46h. None produced a single run-audit row while stranded — the same silent
shape as the FN-8923 incident, whose sweep only covers `held` + `workflowRole: "triage"` +
`blockedReason` starting `workflow-principal-`, and so caught none of these nine.
A third case is pure residue: 33 rows in active states belonged to tasks that were archived AND
soft-deleted, the oldest from 2026-07-13. The FK is `ON DELETE CASCADE`, which only fires on a HARD
delete, so soft-deleting a task strands its continuations forever. They cannot run (their task is gone
from every scan) but they are counted by every `ACTIVE_WORKFLOW_WORK_ITEM_STATES` filter in the engine.
This module is the pure decision shared by the sweep and its tests, so "what is reclaimable" cannot
drift from "what the sweep reclaims" — the drift that let the FN-8923 sweep ship covering one ninth of
the problem it was written for.
*/
/** Terminal disposition for a row whose task can never run it again. */
export const RECLAIM_RETIRED_STATE = "cancelled" as const;
export type StrandedContinuationAction = "requeue" | "retire" | "none";
export type StrandedContinuationReason =
| "engine-paused"
| "task-terminal"
| "task-missing"
| "operator-paused"
| "manual-hold"
| "live-session"
| "too-fresh"
| "lease-active"
| "scheduler-owned"
| "dead-lease"
| "unclaimable-hold";
export interface StrandedContinuationVerdict {
action: StrandedContinuationAction;
reason: StrandedContinuationReason;
}
/**
* FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
* Decide what to do with ONE continuation row. Ordering is deliberate and load-bearing:
*
* - `enginePaused` wins over everything. A paused engine must not silently re-queue work an operator
* stopped; the row is still stranded when the engine resumes and the next sweep sees it.
* - Retirement is tested BEFORE the pause and liveness gates. A soft-deleted or archived task has no
* operator decision left to respect and no session that could be live, and its row is exactly the
* residue that accumulates for a month when this check sits behind those guards.
* - `manual-hold` is an operator-owned kind (`WORKFLOW_WORK_ITEM_KINDS`), not an accident. Its whole
* purpose is to stop until a human acts, so automatic reclaim must never touch it.
* - The liveness proof is the caller's (the canonical `activeSessionRegistry` / `executingTaskLock` /
* `isTaskActive` triple). A live session with a NULL-expiry lease is the exact false positive that
* would double-dispatch a running task, so `live` outranks the dead-lease test below it.
* - `lease-active` still defers to a real unexpired lease even past the grace window: an expiry in the
* future is affirmative proof of a live claim, which staleness alone never is.
*
* @param input.item The continuation row under consideration.
* @param input.taskTerminal The owning task is deleted, archived, or otherwise past running this row.
* @param input.taskMissing No task row resolved for `item.taskId` at all.
* @param input.live Caller-proven live execution for the owning task.
* @param input.stalenessMs Age of the row's last update.
* @param input.graceMs Minimum age before a row is considered abandoned.
*/
export function evaluateStrandedContinuationReclaim(input: {
item: Pick<WorkflowWorkItem, "state" | "kind" | "leaseExpiresAt" | "blockedReason">;
taskTerminal: boolean;
taskMissing: boolean;
taskPaused: boolean;
live: boolean;
enginePaused: boolean;
stalenessMs: number;
graceMs: number;
now: number;
}): StrandedContinuationVerdict {
if (input.enginePaused) return { action: "none", reason: "engine-paused" };
if (input.taskMissing) return { action: "retire", reason: "task-missing" };
if (input.taskTerminal) return { action: "retire", reason: "task-terminal" };
if (!ACTIVE_WORKFLOW_WORK_ITEM_STATES.includes(input.item.state)) {
return { action: "none", reason: "scheduler-owned" };
}
if (input.item.kind === "manual-hold") return { action: "none", reason: "manual-hold" };
if (input.taskPaused) return { action: "none", reason: "operator-paused" };
if (input.live) return { action: "none", reason: "live-session" };
/*
FNXC:StrandedContinuationReclaim 2026-08-11-09:12:
`runnable`/`retrying` rows are the dispatcher's own queue. They are only reachable here through the
retirement branches above (a dead task's row), never for reclaim — re-writing a live queue entry
would reset its scheduling position for no gain.
*/
if (input.item.state === "runnable" || input.item.state === "retrying") {
return { action: "none", reason: "scheduler-owned" };
}
if (input.stalenessMs < input.graceMs) return { action: "none", reason: "too-fresh" };
if (input.item.state === "running") {
const expiresAt = input.item.leaseExpiresAt ? Date.parse(input.item.leaseExpiresAt) : Number.NaN;
if (Number.isFinite(expiresAt) && expiresAt > input.now) return { action: "none", reason: "lease-active" };
return { action: "requeue", reason: "dead-lease" };
}
return { action: "requeue", reason: "unclaimable-hold" };
}