fix(agents): separate heartbeat runtime from workflow routability

runtimeConfig.enabled answered two different questions. Every consumer in the
engine reads it as "run this agent's own durable heartbeat loop" — heartbeat
scheduling, error recovery, self-healing, in-process runtime — except the
workflow router, which also read it as "may own a workflow stage".

That conflation caused both failures:

- Built-in owners ship with the heartbeat off, which is CORRECT (they are
  invoked by the workflow engine and must not run autonomous loops or auto-claim
  work). That silently made every built-in role unroutable and deadlocked the
  board.
- Enabling the heartbeat to restore routing then gave four agents autonomous
  loops and auto-claiming nobody asked for.

Separate the flag:

- runtimeConfig.enabled governs the heartbeat runtime ONLY
- isWorkflowPrincipalEligible answers routability, and treats the four built-in
  role owners as routable structurally — there is no fallback if a role cannot
  route, so "unroutable" is not a state an operator can meaningfully select
- provision built-ins { enabled: false, autoClaimRelevantTasks: false }
- paused/errored still outranks the exemption, so it can never resurrect a
  broken principal

Removes the earlier write-seam coercion that forced enabled:true — the invariant
is now structural rather than fought for on every write.

Also re-applies the principal-hold backoff ladder (15s -> 5m, checked before
graph entry) into executor/execute-workflow-graph.ts. PR #3317's executor peel
rewrote executor.ts from a pre-change base and dropped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-09 19:03:23 -07:00
parent 1cf86baa1c
commit 51a5e1c275
6 changed files with 127 additions and 97 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Built-in workflow agents no longer need heartbeats enabled to receive work.
category: fix
dev: `runtimeConfig.enabled` governs the durable heartbeat runtime only; workflow-stage routability is answered by `isWorkflowPrincipalEligible`, which treats the four built-in role owners (triage/executor/reviewer/merger) as routable structurally. Built-ins are provisioned `{ enabled: false, autoClaimRelevantTasks: false }` — no autonomous loops, no auto-claim — while every built-in role stays routable. Replaces the earlier write-seam coercion that forced `enabled: true`. Also re-applies the principal-hold backoff ladder (15s→5m, checked before graph entry) into `executor/execute-workflow-graph.ts`, where PR #3317's executor peel dropped it.

View File

@@ -11,17 +11,16 @@ reviewer, merger) with `runtimeConfig: { enabled: false }`, while the router's `
unroutable BY CONSTRUCTION — a defect every fresh instance ships with, not a local misconfiguration. Nothing unroutable BY CONSTRUCTION — a defect every fresh instance ships with, not a local misconfiguration. Nothing
recovers on its own, because the pool can only change through operator action. recovers on its own, because the pool can only change through operator action.
Invariant under test: a built-in workflow role owner is always routable. It is coerced back to routable at the First fix attempt made it worse in a different way: enabling the heartbeat to restore routing gave four agents
durable write seam, so no caller — REST, dashboard toggle, plugin, provisioning, config restore — can put the autonomous loops and auto-claiming nobody wanted. The real defect is that ONE flag answered two questions.
system into the deadlocked state; and the static routability predicate is SHARED with the router so
"what provisioning produces" and "what routing accepts" cannot drift apart again. Invariant under test: `runtimeConfig.enabled` governs the durable heartbeat runtime ONLY, and a built-in
workflow role owner is routable regardless of it. So the built-ins keep heartbeats and auto-claim OFF — which
is what they should be, since the workflow engine invokes them — while every built-in role stays routable. The
predicate is SHARED with the router so "what provisioning produces" and "what routing accepts" cannot drift.
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { isBuiltinWorkflowRoleAgent, isWorkflowPrincipalEligible } from "../agent-role-policy.js";
enforceBuiltinWorkflowRoleRoutability,
isBuiltinWorkflowRoleAgent,
isWorkflowPrincipalEligible,
} from "../agent-role-policy.js";
const builtIn = (runtimeConfig?: Record<string, unknown>) => ({ const builtIn = (runtimeConfig?: Record<string, unknown>) => ({
id: "agent-builtin", id: "agent-builtin",
@@ -36,48 +35,35 @@ const operatorOwned = (runtimeConfig?: Record<string, unknown>) => ({
}); });
describe("built-in workflow role routability invariant", () => { describe("built-in workflow role routability invariant", () => {
it("coerces a disabled built-in owner back to routable", () => { it("routes a built-in owner whose heartbeat runtime is off", () => {
const result = enforceBuiltinWorkflowRoleRoutability(builtIn({ enabled: false })); // The exact production state: heartbeat disabled (correct) must NOT mean unroutable.
expect(result.runtimeConfig).toMatchObject({ enabled: true }); expect(isWorkflowPrincipalEligible(builtIn({ enabled: false }))).toBe(true);
expect(isWorkflowPrincipalEligible(result)).toBe(true); expect(isWorkflowPrincipalEligible(builtIn({ enabled: false, autoClaimRelevantTasks: false }))).toBe(true);
}); });
it("preserves every other runtimeConfig key while coercing", () => { it("keeps the heartbeat flag meaning only 'run the autonomous loop'", () => {
// The operator's heartbeat cadence and claim policy are theirs; only routability is non-negotiable. // Routability must not be achievable only by switching the heartbeat on — that regression gave four
const result = enforceBuiltinWorkflowRoleRoutability( // agents autonomous loops and auto-claiming nobody asked for.
builtIn({ enabled: false, heartbeatIntervalMs: 3_600_000, autoClaimRelevantTasks: true }), const off = builtIn({ enabled: false });
); const on = builtIn({ enabled: true });
expect(result.runtimeConfig).toEqual({ expect(isWorkflowPrincipalEligible(off)).toBe(isWorkflowPrincipalEligible(on));
enabled: true,
heartbeatIntervalMs: 3_600_000,
autoClaimRelevantTasks: true,
});
}); });
it("leaves an operator-owned agent free to be disabled", () => { it("leaves an operator-owned agent unroutable when disabled", () => {
// The invariant protects the engine's own principals, not every agent — operators keep their off switch. // The structural exemption is scoped to the engine's own principals; operators keep their off switch.
const result = enforceBuiltinWorkflowRoleRoutability(operatorOwned({ enabled: false })); expect(isWorkflowPrincipalEligible(operatorOwned({ enabled: false }))).toBe(false);
expect(result.runtimeConfig).toMatchObject({ enabled: false }); expect(isWorkflowPrincipalEligible(operatorOwned({ enabled: true }))).toBe(true);
expect(isWorkflowPrincipalEligible(result)).toBe(false); expect(isBuiltinWorkflowRoleAgent(operatorOwned())).toBe(false);
expect(isBuiltinWorkflowRoleAgent(result)).toBe(false);
});
it("is a no-op for an already-routable built-in owner (same reference, no churn)", () => {
const agent = builtIn({ enabled: true });
expect(enforceBuiltinWorkflowRoleRoutability(agent)).toBe(agent);
const unset = builtIn();
expect(enforceBuiltinWorkflowRoleRoutability(unset)).toBe(unset);
}); });
/* /*
The predicate the router consults. `enabled === false` was the exact bit that made the pool look exhausted; Paused/errored is a genuine unusability signal, so it outranks the built-in exemption — otherwise the
paused/errored agents must stay excluded so the coercion never resurrects a genuinely broken principal. exemption would resurrect a broken principal and route work into it.
*/ */
it("still excludes paused and errored agents from principal routing", () => { it("still excludes paused and errored agents, including built-ins", () => {
expect(isWorkflowPrincipalEligible({ runtimeConfig: { enabled: true }, state: "paused" })).toBe(false); expect(isWorkflowPrincipalEligible({ ...builtIn({ enabled: false }), state: "paused" })).toBe(false);
expect(isWorkflowPrincipalEligible({ runtimeConfig: { enabled: true }, state: "error" })).toBe(false); expect(isWorkflowPrincipalEligible({ ...builtIn({ enabled: false }), state: "error" })).toBe(false);
expect(isWorkflowPrincipalEligible({ runtimeConfig: { enabled: true }, state: "active" })).toBe(true); expect(isWorkflowPrincipalEligible({ ...builtIn({ enabled: false }), state: "active" })).toBe(true);
// An unset runtimeConfig is routable: only an explicit `false` opts an agent out. expect(isWorkflowPrincipalEligible({ ...operatorOwned(), state: "active" })).toBe(true);
expect(isWorkflowPrincipalEligible({ state: "active" })).toBe(true);
}); });
}); });

View File

@@ -96,35 +96,37 @@ export function isBuiltinWorkflowRoleAgent(agent: { metadata?: Record<string, un
return agent.metadata?.builtInWorkflowRole === true; return agent.metadata?.builtInWorkflowRole === true;
} }
/** /*
* FNXC:WorkflowAgentRouting 2026-08-10-01:15: FNXC:WorkflowAgentRouting 2026-08-10-01:15:
* HARD INVARIANT: a built-in workflow role owner is never unroutable. `runtimeConfig.enabled` means ONE thing: run this agent's own durable heartbeat loop. Every consumer in the
* engine reads it that way — heartbeat scheduling, error recovery, self-healing, the in-process runtime — except
* These four are the engine's own principals for triage/executor/reviewer/merger. Unlike an operator's agent, the workflow router, which also treated it as "may own a workflow stage". Those are different questions, and
* disabling one does not "opt an agent out" — it removes the only thing that can run that workflow stage, and conflating them is what produced BOTH failures here:
* the engine has no fallback: every task holds at its first node of that role and the hold re-dispatches
* forever. That is not a configuration an operator can meaningfully choose, so `runtimeConfig.enabled` is - Built-in owners ship with the heartbeat off (correct — they are invoked BY the workflow engine and must not
* COERCED back to true for them at the write seam rather than validated and rejected: the write still run autonomous loops or auto-claim work), and that silently made every built-in role unroutable, deadlocking
* succeeds, every other runtimeConfig key the caller sent is preserved, and the system cannot be put into the the board.
* deadlocked state by an API call, a UI toggle, a plugin, or a stale record. - Turning the heartbeat on to restore routing then gave four agents autonomous loops nobody asked for.
*
* To take a built-in owner out of rotation, add your own agent with that role and route to it — that path So the flag is separated: `enabled` governs the heartbeat runtime ONLY, and workflow routability is answered by
* leaves the role routable, which is the property this protects. {@link isWorkflowPrincipalEligible}. For the four built-in owners routability is STRUCTURAL — they are the
*/ engine's own principals for triage/executor/reviewer/merger, there is no fallback if a role cannot route, and
export function enforceBuiltinWorkflowRoleRoutability<T extends { "unroutable" is not a state an operator can meaningfully select. To take one out of rotation, add your own
metadata?: Record<string, unknown> | null; agent with that role and route to it; that leaves the role routable, which is the property this protects.
runtimeConfig?: Record<string, unknown> | null; */
}>(agent: T): T {
if (!isBuiltinWorkflowRoleAgent(agent)) return agent;
if (agent.runtimeConfig?.enabled !== false) return agent;
return { ...agent, runtimeConfig: { ...agent.runtimeConfig, enabled: true } };
}
export function isWorkflowPrincipalEligible( export function isWorkflowPrincipalEligible(
agent: Pick<RoleTaggedAgent, "runtimeConfig"> & { state?: string; id?: string }, agent: Pick<RoleTaggedAgent, "runtimeConfig"> & {
state?: string;
id?: string;
metadata?: Record<string, unknown> | null;
},
): boolean { ): boolean {
if (agent.runtimeConfig?.enabled === false) return false; // A paused or errored agent is genuinely unusable — that applies to built-ins too, so it is checked first.
return agent.state !== "paused" && agent.state !== "error"; if (agent.state === "paused" || agent.state === "error") return false;
// Built-in owners route regardless of their heartbeat setting; see the note above.
if (isBuiltinWorkflowRoleAgent(agent)) return true;
return agent.runtimeConfig?.enabled !== false;
} }
export function isImplementationTask(task: Pick<Task, "column">): boolean { export function isImplementationTask(task: Pick<Task, "column">): boolean {

View File

@@ -110,7 +110,6 @@ import {
BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST, BUILTIN_WORKFLOW_ROLE_AGENT_DEFAULT_LIST,
type BuiltinWorkflowRole, type BuiltinWorkflowRole,
} from "./workflow-role-agent-defaults.js"; } from "./workflow-role-agent-defaults.js";
import { enforceBuiltinWorkflowRoleRoutability } from "./agent-role-policy.js";
const agentStoreLog = createLogger("agent-store"); const agentStoreLog = createLogger("agent-store");
@@ -2061,14 +2060,12 @@ export class AgentStore extends EventEmitter {
metadata: { builtInWorkflowRole: true, workflowRole: definition.role }, metadata: { builtInWorkflowRole: true, workflowRole: definition.role },
/* /*
FNXC:WorkflowAgentRouting 2026-08-10-01:15: FNXC:WorkflowAgentRouting 2026-08-10-01:15:
These four ARE the permanent principals that route built-in workflow stages, and the router treats Heartbeat OFF and auto-claim OFF is correct for these four: they are invoked BY the workflow engine
`runtimeConfig.enabled === false` as unavailable. Provisioning them disabled therefore created a as stage principals and must not run autonomous loops or claim work on their own. This no longer
system that could not route ANY built-in role: every task held at its first workflow node with costs routability — `isWorkflowPrincipalEligible` treats built-in owners as routable structurally,
`workflow-principal-role-pool-exhausted:<role>`, and the held item was re-claimed with no backoff, precisely so the heartbeat setting and the routing question stay separate concerns.
burning CPU and ~19k audit rows/hour while nothing executed. Seed them ENABLED so a fresh instance
can route out of the box; an operator can still disable one deliberately afterwards.
*/ */
runtimeConfig: { enabled: true }, runtimeConfig: { enabled: false, autoClaimRelevantTasks: false },
instructionsText: definition.instructionsText, instructionsText: definition.instructionsText,
soul: definition.soul, soul: definition.soul,
bundleConfig: { ...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG, files: [...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG.files] }, bundleConfig: { ...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG, files: [...BUILTIN_WORKFLOW_AGENT_BUNDLE_CONFIG.files] },
@@ -2080,11 +2077,6 @@ export class AgentStore extends EventEmitter {
|| (agent.bundleConfig !== undefined && !canonicalOrPartialBundle) || (agent.bundleConfig !== undefined && !canonicalOrPartialBundle)
|| (Boolean(agent.instructionsText?.trim()) && agent.instructionsText !== definition.instructionsText); || (Boolean(agent.instructionsText?.trim()) && agent.instructionsText !== definition.instructionsText);
const updates: Partial<Agent> = {}; const updates: Partial<Agent> = {};
// FNXC:WorkflowAgentRouting 2026-08-10-01:15: converge every existing built-in owner to routable.
// See enforceBuiltinWorkflowRoleRoutability — routability is an invariant for these four, not a setting.
if (agent.runtimeConfig?.enabled === false) {
updates.runtimeConfig = { ...agent.runtimeConfig, enabled: true };
}
if (!customInstructions && !agent.instructionsText?.trim()) updates.instructionsText = definition.instructionsText; if (!customInstructions && !agent.instructionsText?.trim()) updates.instructionsText = definition.instructionsText;
if (!agent.soul?.trim()) updates.soul = definition.soul; if (!agent.soul?.trim()) updates.soul = definition.soul;
// FNXC:WorkflowAgentIdentities 2026-08-08-06:38: Do not rewrite complete canonical // FNXC:WorkflowAgentIdentities 2026-08-08-06:38: Do not rewrite complete canonical
@@ -3198,16 +3190,9 @@ export class AgentStore extends EventEmitter {
} }
private async writeAgent(agent: Agent, executor?: QueryHandle): Promise<void> { private async writeAgent(agent: Agent, executor?: QueryHandle): Promise<void> {
/*
FNXC:WorkflowAgentRouting 2026-08-10-01:15:
The single durable write seam for agents, and therefore the only place the built-in-owner routability
invariant cannot be bypassed. Enforcing here — rather than in the REST handler — covers the API, the
dashboard toggle, plugins, provisioning, config-revision restores, and any future caller at once.
*/
const routable = enforceBuiltinWorkflowRoleRoutability(agent);
// FNXC:SqliteFinalRemoval 2026-06-25-23:40: // FNXC:SqliteFinalRemoval 2026-06-25-23:40:
// Backend mode: delegate to async Drizzle writeAgent helper. // Backend mode: delegate to async Drizzle writeAgent helper.
await writeAgentAsync(executor ?? this.asyncLayer!.db, routable, this.asyncLayer!.projectId); await writeAgentAsync(executor ?? this.asyncLayer!.db, agent, this.asyncLayer!.projectId);
return; return;
} }

View File

@@ -798,7 +798,6 @@ export {
canAgentReceiveImplementationTasks, canAgentReceiveImplementationTasks,
isWorkflowPrincipalEligible, isWorkflowPrincipalEligible,
isBuiltinWorkflowRoleAgent, isBuiltinWorkflowRoleAgent,
enforceBuiltinWorkflowRoleRoutability,
evaluateImplementationTaskBind, evaluateImplementationTaskBind,
assertImplementationTaskBindAllowed, assertImplementationTaskBindAllowed,
AgentTaskRoutingPolicyError, AgentTaskRoutingPolicyError,

View File

@@ -103,11 +103,43 @@ export type ExecuteWorkflowGraphDeps = {
terminateAllChildren: AnyFn; terminateAllChildren: AnyFn;
}; };
/*
FNXC:WorkflowAgentRouting 2026-08-10-01:15:
Backoff ladder for a workflow-principal hold. A hold had NO cooldown, so the scheduler re-dispatched instantly
and the run re-entered only to re-fence and re-park: observed at ~3.5 re-dispatches/second across every task,
pinning a core and writing ~19k `workflowWorkItem` audit rows/hour while nothing executed. The hold never
increments the work item's `attempt`, so no retry budget is consumed and no existing guard can ever fire.
Module-scoped and in-memory on purpose, matching the session-contention hold: it needs no schema change, and a
restart clearing it is CORRECT — a restart is exactly when agent configuration may have changed. The ceiling is
generous because an unroutable role clears on OPERATOR action (enable or add an agent), never on its own, so
polling it every few seconds only burns CPU.
*/
const PRINCIPAL_HOLD_BACKOFF_MS = process.env.VITEST || process.env.NODE_ENV === "test" ? 0 : 15_000;
const PRINCIPAL_HOLD_MAX_BACKOFF_MS = 300_000;
const principalHoldBackoff = new Map<string, { reason: string; attempt: number; until: number }>();
/** Clears the ladder for a task; exported so tests and recovery paths can reset it deterministically. */
export function clearPrincipalHoldBackoff(taskId: string): void {
principalHoldBackoff.delete(taskId);
}
export async function executeWorkflowGraph( export async function executeWorkflowGraph(
deps: ExecuteWorkflowGraphDeps, deps: ExecuteWorkflowGraphDeps,
task: Task, task: Task,
opts?: { alreadyClaimed?: boolean }, opts?: { alreadyClaimed?: boolean },
): Promise<void> { ): Promise<void> {
/*
FNXC:WorkflowAgentRouting 2026-08-10-01:15:
Honor an active principal-hold cooldown BEFORE the graph is entered — re-entering only to re-fence and
re-park is the hot loop itself, and it costs a graph run plus two work-item writes and two audit rows per
pass for a condition that cannot change without operator action.
*/
const cooling = principalHoldBackoff.get(task.id);
if (cooling && Date.now() < cooling.until && !opts?.alreadyClaimed) {
executorLog.debug(`[workflow-graph] ${task.id} deferred — principal hold cooling down (${cooling.reason})`);
return;
}
// Claim synchronously before any await so concurrent execute() calls for // Claim synchronously before any await so concurrent execute() calls for
// the same task cannot both enter graph routing (mirrors executingTaskLock). // the same task cannot both enter graph routing (mirrors executingTaskLock).
// executeCore may already have claimed before its pre-graph awaits (FN-8471). // executeCore may already have claimed before its pre-graph awaits (FN-8471).
@@ -546,13 +578,29 @@ export async function executeWorkflowGraph(
* never-clears composition fault (missing agent-store / IR). * never-clears composition fault (missing agent-store / IR).
*/ */
const neverClears = principalHoldReason.startsWith("workflow-principal-routing-unavailable:"); const neverClears = principalHoldReason.startsWith("workflow-principal-routing-unavailable:");
/*
* FNXC:WorkflowAgentRouting 2026-08-10-01:15:
* Record the backoff keyed on the hold REASON. A changed reason resets the ladder (genuinely new
* information); repeats extend it. The first occurrence of a reason still logs immediately so the
* hold stays greppable, while repeats stay silent so neither the engine log nor the task log floods.
*/
const priorHold = principalHoldBackoff.get(task.id);
const repeated = priorHold?.reason === principalHoldReason;
const attempt = repeated ? priorHold!.attempt + 1 : 1;
principalHoldBackoff.set(task.id, {
reason: principalHoldReason,
attempt,
until: Date.now() + Math.min(PRINCIPAL_HOLD_MAX_BACKOFF_MS, PRINCIPAL_HOLD_BACKOFF_MS * 2 ** (attempt - 1)),
});
const holdMessage = `[workflow-graph] ${task.id} held at graph node — ${principalHoldReason}`; const holdMessage = `[workflow-graph] ${task.id} held at graph node — ${principalHoldReason}`;
if (neverClears) { if (!repeated) {
executorLog.error(`${holdMessage} (workflow principal routing is unavailable; this hold cannot self-clear)`); if (neverClears) {
} else { executorLog.error(`${holdMessage} (workflow principal routing is unavailable; this hold cannot self-clear)`);
executorLog.warn(holdMessage); } else {
executorLog.warn(holdMessage);
}
await deps.store.logEntry(task.id, `Workflow stage held — ${principalHoldReason}`).catch(() => undefined);
} }
await deps.store.logEntry(task.id, `Workflow stage held — ${principalHoldReason}`).catch(() => undefined);
if ( if (
continuation continuation
&& typeof deps.store.transitionWorkflowWorkItem === "function" && typeof deps.store.transitionWorkflowWorkItem === "function"
@@ -567,6 +615,9 @@ export async function executeWorkflowGraph(
} }
return; return;
} }
// FNXC:WorkflowAgentRouting 2026-08-10-01:15: this run cleared the principal fence, so any prior hold is
// resolved — drop the ladder so a later hold starts from the short delay rather than a stale long one.
clearPrincipalHoldBackoff(task.id);
/* Direct graph node fences are terminalized only after the interpreter /* Direct graph node fences are terminalized only after the interpreter
* returns, preserving their historical principal through all handler and * returns, preserving their historical principal through all handler and
* tool-gate calls while ensuring completed work cannot render as active. * tool-gate calls while ensuring completed work cannot render as active.