fix(engine): auto-recover false-positive heartbeat-model-unavailable parks

Admit under-budget paused/heartbeat-model-unavailable agents to the shared
heartbeatErrorRecovery budget so timer, self-healing, and startup paths
retry without a manual Retry. Keep the pause reason when the budget is
exhausted so operators still see credential guidance.
This commit is contained in:
gsxdsm
2026-07-15 08:58:33 -07:00
parent e9f14bf024
commit 7a4a9c8229
6 changed files with 422 additions and 57 deletions

View File

@@ -379,7 +379,7 @@ Self-healing intentionally refuses to auto-restart agents when blockers are oper
- **Timer trigger:** run completes and the durable agent returns to `state="active"` (recoverable soft-fail).
- **Assignment / on-demand trigger:** run completes with `resultJson.actionRequired = true`, then the durable agent is paused with `pauseReason="heartbeat-model-unavailable"` and `lastError` set to actionable credential guidance (including the missing provider name when detectable).
After credentials are fixed, operators should resume the paused durable agent; subsequent heartbeats proceed normally.
False-positive `heartbeat-model-unavailable` parks (session/registry/credential-probe blips that a manual Retry would clear without config changes) are admitted to the same bounded `heartbeatErrorRecovery` budget as error-state recovery. The heartbeat timer re-arms while budget remains, the run-entry path clears the park and retries, and the self-healing sweep plus engine-startup reset are backstops. Genuine missing credentials re-park after each failed attempt and stay parked once the budget is exhausted (keeping `pauseReason="heartbeat-model-unavailable"` for operator guidance). Manual resume still works at any time.
### Assigned-agent identity + planning model precedence for task triage

View File

@@ -39,11 +39,13 @@ import {
HEARTBEAT_ERROR_RECOVERY_METADATA_KEY,
HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON,
HEARTBEAT_ERROR_UNRECOVERABLE_PAUSE_REASON,
HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
HeartbeatMonitor,
HeartbeatTriggerScheduler,
incrementHeartbeatErrorRecoveryMetadata,
isErrorRecoveryEligible,
isHeartbeatErrorRecoverable,
isModelUnavailableParkRecoveryEligible,
readHeartbeatErrorRetryCount,
resetHeartbeatErrorRecoveryMetadata,
resolveErrorRecoveryLimit,
@@ -186,6 +188,25 @@ describe("heartbeat error-recovery primitives", () => {
expect(isErrorRecoveryEligible(baseAgent({ lastError: '401 {"type":"error","error":{"type":"authentication_error","message":"OAuth token does not meet scope requirements"}}' }), 5)).toBe(false);
expect(isHeartbeatErrorRecoverable({ lastError: "Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/deleted/node_modules/@runfusion/fusion/dist/bin.js' imported from /tmp/deleted/packages/engine/src/pi.ts" })).toBe(false);
});
it("admits under-budget heartbeat-model-unavailable parks for auto-recovery even when lastError is operator-actionable", () => {
const parked = baseAgent({
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
});
expect(isModelUnavailableParkRecoveryEligible(parked, 5)).toBe(true);
expect(isErrorRecoveryEligible(parked, 5)).toBe(true);
expect(isHeartbeatErrorRecoverable(parked)).toBe(false);
expect(isErrorRecoveryEligible({
...parked,
metadata: buildHeartbeatErrorRecoveryMetadata(parked, 5),
}, 5)).toBe(false);
expect(isErrorRecoveryEligible({
...parked,
pauseReason: "manual",
}, 5)).toBe(false);
});
});
describe("HeartbeatMonitor error-state recovery", () => {
@@ -219,6 +240,58 @@ describe("HeartbeatMonitor error-state recovery", () => {
}));
});
it("auto-retries a false heartbeat-model-unavailable park on the next heartbeat without operator Retry", async () => {
const session = createSession(async () => undefined);
mockedCreateFnAgent.mockResolvedValueOnce(session as never);
const store = createAgentStore(baseAgent({
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
}));
const taskStore = createNoTaskStore();
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: process.cwd() });
await monitor.executeHeartbeat({ agentId: store.agent.id, source: "timer" });
expect(session.prompt).toHaveBeenCalledTimes(1);
expect(store.agent.state).toBe("active");
expect(store.agent.lastError).toBeUndefined();
expect(store.agent.pauseReason).toBeUndefined();
expect(readHeartbeatErrorRetryCount(store.agent)).toBe(0);
expect(taskStore.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "agent:auto-recover-error-state",
target: store.agent.id,
metadata: expect.objectContaining({ attempt: 1, limit: 5, source: "timer" }),
}));
});
it("keeps an exhausted heartbeat-model-unavailable park parked with the same pause reason", async () => {
const store = createAgentStore(baseAgent({
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
metadata: buildHeartbeatErrorRecoveryMetadata(baseAgent(), 5),
}));
const taskStore = createNoTaskStore();
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: process.cwd() });
const result = await monitor.executeHeartbeat({ agentId: store.agent.id, source: "timer" });
expect(result.status).toBe("completed");
expect(result.resultJson).toMatchObject({
reason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
attempts: 5,
limit: 5,
});
expect(store.agent.state).toBe("paused");
expect(store.agent.pauseReason).toBe(HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON);
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(taskStore.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "agent:error-retry-exhausted",
target: store.agent.id,
}));
});
it("treats a generic first-run heartbeat failure as recoverable and auto-recovers on the next heartbeat", async () => {
const genericError = "Failed to start agent session: spawn ENOENT";
const firstSession = createSession(async () => { throw new Error(genericError); });

View File

@@ -0,0 +1,170 @@
/*
FNXC:HeartbeatRecovery 2026-07-15-08:50:
Focused suite for false-positive heartbeat-model-unavailable parks. Kept out of the large
quarantined self-healing.test.ts so default vitest project covers the auto-retry contract.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Agent, AgentStore, Settings, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import {
HEARTBEAT_ERROR_RECOVERY_METADATA_KEY,
HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
readHeartbeatErrorRetryCount,
} from "../agent-heartbeat.js";
vi.mock("../logger.js", () => ({
createLogger: vi.fn(() => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
schedulerLog: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function createStatefulMockAgentStore(agents: Agent[]): AgentStore & { getAgent(id: string): Agent | undefined } {
const agentMap = new Map<string, Agent>(
agents.map((agent) => [agent.id, { ...agent, metadata: agent.metadata ? { ...agent.metadata } : agent.metadata }]),
);
return {
getAgent: (id: string) => agentMap.get(id),
listAgents: vi.fn().mockImplementation(async () => Array.from(agentMap.values())),
updateAgentState: vi.fn().mockImplementation(async (id: string, state: Agent["state"]) => {
const agent = agentMap.get(id);
if (agent) agentMap.set(id, { ...agent, state });
}),
updateAgent: vi.fn().mockImplementation(async (id: string, patch: Partial<Agent>) => {
const agent = agentMap.get(id);
if (agent) agentMap.set(id, { ...agent, ...patch });
}),
} as unknown as AgentStore & { getAgent(id: string): Agent | undefined };
}
describe("SelfHealingManager heartbeat-model-unavailable recovery", () => {
let store: TaskStore;
beforeEach(() => {
vi.clearAllMocks();
store = {
getSettings: vi.fn().mockResolvedValue({
globalPause: false,
enginePaused: false,
taskStuckTimeoutMs: 60_000,
} as unknown as Settings),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("startup resets both misattributed and same-provider heartbeat-model-unavailable parks", async () => {
const now = Date.now();
const agentStore = createStatefulMockAgentStore([
{
id: "misattributed-heartbeat-model",
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true, modelProvider: "grok-cli", modelId: "grok-4.5", model: "grok-cli/grok-4.5" },
updatedAt: new Date(now).toISOString(),
} as unknown as Agent,
{
id: "genuine-heartbeat-model",
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true, modelProvider: "anthropic", modelId: "claude-opus-4-8", model: "anthropic/claude-opus-4-8" },
updatedAt: new Date(now).toISOString(),
} as unknown as Agent,
{
id: "user-paused",
state: "paused",
pauseReason: "manual",
lastError: "socket hang up",
updatedAt: new Date(now).toISOString(),
} as unknown as Agent,
]);
const restartDurableAgentHeartbeat = vi.fn().mockResolvedValue(true);
const manager = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
agentStore,
restartDurableAgentHeartbeat,
});
const resetCount = await manager.resetDurableAgentErrorStateOnStartup();
expect(resetCount).toBe(2);
for (const agentId of ["misattributed-heartbeat-model", "genuine-heartbeat-model"]) {
const agent = agentStore.getAgent(agentId)!;
expect(agent.state).toBe("active");
expect(agent.lastError).toBeUndefined();
expect(agent.pauseReason).toBeUndefined();
expect(readHeartbeatErrorRetryCount(agent)).toBe(0);
}
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("misattributed-heartbeat-model", {
reason: "startup-error-reset",
attempt: 1,
});
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("genuine-heartbeat-model", {
reason: "startup-error-reset",
attempt: 1,
});
expect(agentStore.updateAgentState).not.toHaveBeenCalledWith("user-paused", expect.anything());
manager.stop();
});
it("recoverOrphanedAgents auto-recovers a stale under-budget model-unavailable park", async () => {
const now = Date.now();
const agentStore = createStatefulMockAgentStore([
{
id: "false-model-park",
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true },
metadata: {},
updatedAt: new Date(now - 120_000).toISOString(),
} as unknown as Agent,
{
id: "fresh-model-park",
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true },
updatedAt: new Date(now).toISOString(),
} as unknown as Agent,
{
id: "exhausted-model-park",
state: "paused",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true },
metadata: { [HEARTBEAT_ERROR_RECOVERY_METADATA_KEY]: { consecutiveAttempts: 5 } },
updatedAt: new Date(now - 120_000).toISOString(),
} as unknown as Agent,
]);
const restartDurableAgentHeartbeat = vi.fn().mockResolvedValue(true);
const manager = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
agentStore,
restartDurableAgentHeartbeat,
});
const result = await manager.recoverOrphanedAgents();
expect(result).toBe(1);
expect(agentStore.getAgent("false-model-park")?.state).toBe("active");
expect(agentStore.getAgent("false-model-park")?.pauseReason).toBeUndefined();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "agent:auto-recover-error-state",
target: "false-model-park",
metadata: expect.objectContaining({ agentId: "false-model-park", attempt: 1, limit: 5, source: "self-healing" }),
}));
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("false-model-park", { reason: "transient-error", attempt: 1 });
expect(agentStore.getAgent("fresh-model-park")?.state).toBe("paused");
expect(agentStore.getAgent("exhausted-model-park")?.state).toBe("paused");
manager.stop();
});
});

View File

@@ -1018,7 +1018,7 @@ describe("SelfHealingManager", () => {
await managerWithAgents.runStartupRecovery();
for (const agentId of ["fresh-error", "exhausted-parked", "misattributed-heartbeat-model"]) {
for (const agentId of ["fresh-error", "exhausted-parked", "misattributed-heartbeat-model", "genuine-heartbeat-model"]) {
const agent = agentStore.getAgent(agentId)!;
expect(agent.state).toBe("active");
expect(agent.lastError).toBeUndefined();
@@ -1031,24 +1031,26 @@ describe("SelfHealingManager", () => {
}
expect(agentStore.getAgent("fresh-error")?.metadata?.unrelated).toBe("keep");
expect(agentStore.getAgent("exhausted-parked")?.metadata?.unrelated).toBe("keep-too");
expect(restartDurableAgentHeartbeat).toHaveBeenCalledTimes(3);
expect(restartDurableAgentHeartbeat).toHaveBeenCalledTimes(4);
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("fresh-error", { reason: "startup-error-reset", attempt: 1 });
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("exhausted-parked", { reason: "startup-error-reset", attempt: 1 });
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("misattributed-heartbeat-model", { reason: "startup-error-reset", attempt: 1 });
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("genuine-heartbeat-model", { reason: "startup-error-reset", attempt: 1 });
const resetAudits = recordRunAuditEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.mutationType === "agent:reset-error-state-on-startup");
expect(resetAudits).toHaveLength(3);
expect(resetAudits).toHaveLength(4);
expect(resetAudits).toEqual(expect.arrayContaining([
expect.objectContaining({ target: "fresh-error", metadata: expect.objectContaining({ agentId: "fresh-error", priorState: "error", source: "self-healing" }) }),
expect.objectContaining({ target: "exhausted-parked", metadata: expect.objectContaining({ agentId: "exhausted-parked", priorState: "paused", priorPauseReason: HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON, source: "self-healing" }) }),
expect.objectContaining({ target: "misattributed-heartbeat-model", metadata: expect.objectContaining({ agentId: "misattributed-heartbeat-model", priorState: "paused", priorPauseReason: "heartbeat-model-unavailable", source: "self-healing" }) }),
expect.objectContaining({ target: "genuine-heartbeat-model", metadata: expect.objectContaining({ agentId: "genuine-heartbeat-model", priorState: "paused", priorPauseReason: "heartbeat-model-unavailable", source: "self-healing" }) }),
]));
expect(recordRunAuditEvent.mock.calls.map(([event]) => event.mutationType).filter((type) => type === "agent:auto-recover-error-state")).toHaveLength(0);
expect(agentStore.updateAgentState).toHaveBeenCalledTimes(3);
expect(agentStore.updateAgentState).toHaveBeenCalledTimes(4);
for (const untouchedId of ["operator-actionable", "stale-module", "error-unrecoverable", "genuine-heartbeat-model", "user-paused", "ephemeral", "disabled", "live-agent", "healthy-active", "healthy-idle"]) {
for (const untouchedId of ["operator-actionable", "stale-module", "error-unrecoverable", "user-paused", "ephemeral", "disabled", "live-agent", "healthy-active", "healthy-idle"]) {
expect(agentStore.updateAgentState).not.toHaveBeenCalledWith(untouchedId, expect.anything());
expect(agentStore.updateAgent).not.toHaveBeenCalledWith(untouchedId, expect.anything());
}
@@ -1280,6 +1282,60 @@ describe("SelfHealingManager", () => {
managerWithAgents.stop();
});
it("auto-recovers a stale heartbeat-model-unavailable park even when lastError looks operator-actionable", async () => {
vi.mocked(store.getSettings).mockResolvedValue({ taskStuckTimeoutMs: 60_000 } as unknown as Settings);
const now = Date.now();
const agentStore = createMockAgentStore([
{
id: "false-model-park",
state: "paused",
pauseReason: "heartbeat-model-unavailable",
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true },
metadata: {},
updatedAt: new Date(now - 120_000).toISOString(),
} as Agent,
{
id: "fresh-model-park",
state: "paused",
pauseReason: "heartbeat-model-unavailable",
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true },
updatedAt: new Date(now).toISOString(),
} as Agent,
{
id: "exhausted-model-park",
state: "paused",
pauseReason: "heartbeat-model-unavailable",
lastError: 'No API key for provider: anthropic. Configure credentials for provider "anthropic" in settings, then resume the agent.',
runtimeConfig: { enabled: true },
metadata: { [HEARTBEAT_ERROR_RECOVERY_METADATA_KEY]: { consecutiveAttempts: 5 } },
updatedAt: new Date(now - 120_000).toISOString(),
} as Agent,
]);
const restartDurableAgentHeartbeat = vi.fn().mockResolvedValue(true);
const managerWithAgents = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
agentStore,
restartDurableAgentHeartbeat,
});
const result = await managerWithAgents.recoverOrphanedAgents();
expect(result).toBe(1);
expect(agentStore.updateAgentState).toHaveBeenCalledWith("false-model-park", "active");
expect(agentStore.updateAgent).toHaveBeenCalledWith("false-model-park", { lastError: undefined, pauseReason: undefined });
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "agent:auto-recover-error-state",
target: "false-model-park",
metadata: expect.objectContaining({ agentId: "false-model-park", attempt: 1, limit: 5, source: "self-healing" }),
}));
expect(restartDurableAgentHeartbeat).toHaveBeenCalledWith("false-model-park", { reason: "transient-error", attempt: 1 });
expect(agentStore.updateAgentState).not.toHaveBeenCalledWith("fresh-model-park", expect.anything());
expect(agentStore.updateAgentState).not.toHaveBeenCalledWith("exhausted-model-park", expect.anything());
managerWithAgents.stop();
});
it("recovers only the eligible manager-present agent among a mixed cluster without touching healthy siblings", async () => {
vi.mocked(store.getSettings).mockResolvedValue({ taskStuckTimeoutMs: 60_000 } as unknown as Settings);
const now = Date.now();

View File

@@ -73,11 +73,15 @@ FN-7835 requires durable heartbeat-managed agents in error state to retry on the
FNXC:HeartbeatRecovery 2026-07-11-00:00:
FN-7672 requires durable agent error recovery to stay classification-gated: only transient, non-operator-actionable lastError values may be retried automatically. Credential, quota, model-access, and permanent configuration failures must remain parked for operator action instead of burning heartbeat retries.
FNXC:HeartbeatRecovery 2026-07-15-08:50:
heartbeat-model-unavailable parks from assignment/on-demand runs were terminal until a human Retry, even when the next attempt succeeds with unchanged credentials (false "model unavailable" / registry / credential-probe blips). Admit those parks to the same bounded heartbeatErrorRecovery budget as error-state recovery so the engine auto-retries like operator Retry, while genuine missing credentials re-park after the budget exhausts.
*/
export const MAX_HEARTBEAT_ERROR_RECOVERY_ATTEMPTS = 5;
export const HEARTBEAT_ERROR_RECOVERY_METADATA_KEY = "heartbeatErrorRecovery";
export const HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON = "error-retry-exhausted";
export const HEARTBEAT_ERROR_UNRECOVERABLE_PAUSE_REASON = "error-unrecoverable";
export const HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON = "heartbeat-model-unavailable";
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, type EngineRunContext } from "./run-audit.js";
import { promptWithFallback } from "./pi.js";
@@ -2382,35 +2386,56 @@ export class HeartbeatMonitor {
return (await this.store.getRunDetail(agentId, run.id))!;
}
if (agent.state === "error") {
/*
FNXC:HeartbeatRecovery 2026-07-15-08:50:
Include paused/heartbeat-model-unavailable in the same run-entry recovery gate as bare error. Assignment/on-demand model-unavailable parks previously never re-entered the timer path, so false positives stayed parked until a human Retry even though the next session start would succeed.
*/
if (agent.state === "error" || isModelUnavailablePark(agent)) {
const errorRecoveryLimit = resolveErrorRecoveryLimit(heartbeatModelSettings);
const currentRetryCount = readHeartbeatErrorRetryCount(agent);
const canAttemptErrorRecovery = isErrorRecoveryEligible(agent, errorRecoveryLimit);
const recoveryBudgetExhausted = isHeartbeatManaged(agent)
&& agent.runtimeConfig?.enabled !== false
&& isHeartbeatErrorRecoverable(agent)
&& currentRetryCount >= errorRecoveryLimit;
&& currentRetryCount >= errorRecoveryLimit
&& (isHeartbeatErrorRecoverable(agent) || isModelUnavailablePark(agent));
if (canAttemptErrorRecovery) {
const attempt = currentRetryCount + 1;
const metadata = incrementHeartbeatErrorRecoveryMetadata(agent);
try {
await this.store.updateAgentState(agentId, "active");
await this.store.updateAgent(agentId, { lastError: undefined, metadata });
heartbeatLog.log(`Agent ${agentId} auto-recovered from error state for heartbeat retry attempt ${attempt}/${errorRecoveryLimit}`);
await this.store.updateAgent(agentId, {
lastError: undefined,
pauseReason: undefined,
metadata,
});
heartbeatLog.log(`Agent ${agentId} auto-recovered from ${agent.state === "error" ? "error state" : "heartbeat-model-unavailable park"} for heartbeat retry attempt ${attempt}/${errorRecoveryLimit}`);
await audit.database({
type: "agent:auto-recover-error-state",
target: agentId,
metadata: { agentId, attempt, limit: errorRecoveryLimit, source },
});
agent = (await this.store.getAgent(agentId)) ?? { ...agent, state: "active", lastError: undefined, metadata };
agent = (await this.store.getAgent(agentId)) ?? {
...agent,
state: "active",
lastError: undefined,
pauseReason: undefined,
metadata,
};
} catch (recoveryErr) {
heartbeatLog.warn(`Agent ${agentId} error-state recovery bookkeeping failed: ${recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)} — continuing with existing state`);
}
} else if (recoveryBudgetExhausted) {
try {
// startRun may have flipped the agent to "running"; restore a parked terminal state.
// Exhausted model-unavailable parks keep their pause reason so the UI still
// points at credentials/model config rather than a generic retry-exhausted label.
await this.store.updateAgentState(agentId, "paused");
await this.store.updateAgent(agentId, { pauseReason: HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON });
await this.store.updateAgent(agentId, {
pauseReason: isModelUnavailablePark(agent)
? HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON
: HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON,
});
heartbeatLog.warn(`Agent ${agentId} error recovery exhausted after ${currentRetryCount}/${errorRecoveryLimit} attempts — pausing`);
await audit.database({
type: "agent:error-retry-exhausted",
@@ -2422,12 +2447,19 @@ export class HeartbeatMonitor {
}
await this.completeRun(agentId, run.id, {
status: "completed",
resultJson: { reason: HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON, attempts: currentRetryCount, limit: errorRecoveryLimit },
resultJson: {
reason: isModelUnavailablePark(agent)
? HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON
: HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON,
attempts: currentRetryCount,
limit: errorRecoveryLimit,
},
skipStateTransition: true,
});
return (await this.store.getRunDetail(agentId, run.id))!;
} else if (
isHeartbeatManaged(agent)
agent.state === "error"
&& isHeartbeatManaged(agent)
&& agent.runtimeConfig?.enabled !== false
&& !isStaleWorktreeModuleResolutionError(agent.lastError ?? "")
) {
@@ -2454,9 +2486,11 @@ export class HeartbeatMonitor {
});
return (await this.store.getRunDetail(agentId, run.id))!;
} else {
heartbeatLog.log(`Agent ${agentId} state is "error" but lastError is not eligible for heartbeat recovery — graceful exit`);
heartbeatLog.log(`Agent ${agentId} state is "${agent.state}" but is not eligible for heartbeat recovery — graceful exit`);
try {
await this.store.updateAgentState(agentId, "error");
if (agent.state === "error") {
await this.store.updateAgentState(agentId, "error");
}
} catch (restoreErr) {
heartbeatLog.warn(`Agent ${agentId} non-recoverable error-state restore failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)} — preserving run completion`);
}
@@ -2999,7 +3033,7 @@ export class HeartbeatMonitor {
await this.store.updateAgentState(agentId, "paused");
await this.store.updateAgent(agentId, {
pauseReason: "heartbeat-model-unavailable",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: detail,
});
};
@@ -3708,7 +3742,7 @@ export class HeartbeatMonitor {
});
await this.store.updateAgentState(agentId, "paused");
await this.store.updateAgent(agentId, {
pauseReason: "heartbeat-model-unavailable",
pauseReason: HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON,
lastError: detail,
});
}
@@ -4387,7 +4421,31 @@ export function isHeartbeatErrorRecoverable(agent: Pick<Agent, "lastError">): bo
return !isStaleWorktreeModuleResolutionError(lastError) && !isOperatorActionableAgentError(lastError);
}
export function isModelUnavailablePark(agent: Pick<Agent, "state" | "pauseReason">): boolean {
// Key on pauseReason, not only state=paused: startRun flips the agent to
// "running" before the run-entry recovery gate reads it, and a failed preload
// can load the post-startRun store row. Matching only state=paused would miss
// budgeted auto-retry for those paths.
return agent.pauseReason === HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON
&& agent.state !== "active"
&& agent.state !== "idle";
}
/*
FNXC:HeartbeatRecovery 2026-07-15-08:50:
False-positive heartbeat-model-unavailable parks must stay on the timer path with a bounded budget. Operator-actionable lastError text (no API key / registry miss) would otherwise exclude them from isHeartbeatErrorRecoverable forever, so this park reason is an explicit second recovery admission path independent of lastError classification.
*/
export function isModelUnavailableParkRecoveryEligible(agent: Agent, limit: number): boolean {
return isModelUnavailablePark(agent)
&& isHeartbeatManaged(agent)
&& agent.runtimeConfig?.enabled !== false
&& readHeartbeatErrorRetryCount(agent) < Math.max(1, Math.floor(limit));
}
export function isErrorRecoveryEligible(agent: Agent, limit: number): boolean {
if (isModelUnavailableParkRecoveryEligible(agent, limit)) {
return true;
}
return agent.state === "error"
&& isHeartbeatManaged(agent)
&& agent.runtimeConfig?.enabled !== false
@@ -4400,7 +4458,7 @@ export function isErrorRecoveryEligible(agent: Agent, limit: number): boolean {
*
* Timers are armed only for durable agents where all of the following hold:
* - `runtimeConfig.enabled !== false`
* - `state ∈ {active, running, idle}` or `state === "error"` with retry budget remaining
* - `state ∈ {active, running, idle}`, or `state === "error"` / `paused`+`heartbeat-model-unavailable` with retry budget remaining
*
* Any other state, or any ephemeral/task-worker agent, clears the timer.
* State changes and heartbeat config updates are observed via AgentStore

View File

@@ -50,6 +50,8 @@ import {
HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON,
HEARTBEAT_ERROR_UNRECOVERABLE_PAUSE_REASON,
isHeartbeatErrorRecoverable,
isModelUnavailablePark,
isModelUnavailableParkRecoveryEligible,
readHeartbeatErrorRetryCount,
resetHeartbeatErrorRecoveryMetadata,
resolveErrorRecoveryLimit,
@@ -91,32 +93,9 @@ import type { GhostBugDecision } from "./triage-preflight.js";
import { DependencyBlockedTodoReporter } from "./dependency-blocked-todo-reporter.js";
import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap, shouldHoldActiveFileScopeLease } from "./scheduler.js";
import { evaluateParkedAgentTaskLink, PARKED_AGENT_LINK_FRESH_RUN_MS } from "./task-agent-sync.js";
import { extractRuntimeModel } from "./agent-session-helpers.js";
const log = createLogger("self-healing");
const OPTIONAL_STEP_REVISION_KEY_MARKER = "Workflow revision key:";
const HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON = "heartbeat-model-unavailable";
function extractHeartbeatUnavailableProvider(error: string | undefined): string | undefined {
if (!error) return undefined;
const rawProvider = /no api key for provider:\s*([^\s)]+)/i.exec(error)?.[1]
?? /configured primary model\s+([^/\s]+)\//i.exec(error)?.[1];
return rawProvider?.replace(/["'.,:;]+$/g, "").trim() || undefined;
}
function isMisattributedHeartbeatModelPark(agent: Agent): boolean {
if (agent.state !== "paused" || agent.pauseReason !== HEARTBEAT_MODEL_UNAVAILABLE_PAUSE_REASON) {
return false;
}
const assignedModel = extractRuntimeModel((agent.runtimeConfig ?? {}) as Record<string, unknown>);
const failedProvider = extractHeartbeatUnavailableProvider(agent.lastError);
return Boolean(
assignedModel.provider
&& assignedModel.modelId
&& failedProvider
&& assignedModel.provider.toLowerCase() !== failedProvider.toLowerCase(),
);
}
function normalizeOptionalStepRevisionKey(value: string | undefined): string {
return (value ?? "").trim().toLowerCase();
@@ -10600,6 +10579,9 @@ export class SelfHealingManager {
FNXC:AgentHeartbeat 2026-07-14-16:13:
Startup must also recover a `heartbeat-model-unavailable` park when its recorded failing provider differs from the agent's complete assigned runtime model. This repairs agents falsely parked by the former shared-project-model precedence while preserving genuine assigned-provider authentication failures for operator action.
FNXC:AgentHeartbeat 2026-07-15-08:50:
Startup now recovers every `heartbeat-model-unavailable` park (not only misattributed providers). Engine restart is treated like operator Retry: false model-unavailable/credential-probe parks clear immediately, and genuine missing credentials re-park on the next failing heartbeat.
*/
async resetDurableAgentErrorStateOnStartup(): Promise<number> {
const agentStore = this.options.agentStore;
@@ -10613,8 +10595,8 @@ export class SelfHealingManager {
for (const agent of allAgents) {
const isErrorRetryExhaustedPark =
agent.state === "paused" && agent.pauseReason === HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON;
const isMisattributedModelPark = isMisattributedHeartbeatModelPark(agent);
if (agent.state !== "error" && !isErrorRetryExhaustedPark && !isMisattributedModelPark) {
const isModelUnavailableParked = isModelUnavailablePark(agent);
if (agent.state !== "error" && !isErrorRetryExhaustedPark && !isModelUnavailableParked) {
continue;
}
if (isEphemeralAgent(agent)) {
@@ -10627,7 +10609,7 @@ export class SelfHealingManager {
if (this.options.hasActiveAgentExecution?.(agent.id) === true) {
continue;
}
if ((!isMisattributedModelPark && !isHeartbeatErrorRecoverable(agent)) || isStaleWorktreeModuleResolutionError(agent.lastError ?? "")) {
if ((!isModelUnavailableParked && !isHeartbeatErrorRecoverable(agent)) || isStaleWorktreeModuleResolutionError(agent.lastError ?? "")) {
log.warn(`Startup durable-agent error reset suppressed for ${agent.id}: unrecoverable or stale-module error requires existing recovery path`);
continue;
}
@@ -10696,6 +10678,9 @@ export class SelfHealingManager {
/*
FNXC:AgentHeartbeat 2026-07-12-20:10:
An agent parked paused/"error-unrecoverable" whose lastError NOW classifies as recoverable (e.g. transient OAuth token-rotation 401s that were misclassified operator-actionable before isTransientAuthCredentialError existed) must not stay parked forever waiting for a human. Re-admit exactly those parked agents to the error-recovery sweep; user pauses and every other pauseReason are untouched. The shared retry budget, cooldown, and staleness gates below still apply.
FNXC:AgentHeartbeat 2026-07-15-08:50:
Also re-admit stale paused/heartbeat-model-unavailable parks when shared retry budget remains. Manual Retry already proves many of these are false positives; the timer path is the fast recovery, and this sweep is the backstop when timers were cleared on park.
*/
const isReclassifiedRecoverableParkedError = (agent: Agent): boolean =>
agent.state === "paused"
@@ -10706,7 +10691,13 @@ export class SelfHealingManager {
if (isEphemeralAgent(agent)) {
return false;
}
if (agent.state !== "running" && agent.state !== "error" && !isReclassifiedRecoverableParkedError(agent)) {
const isModelUnavailableRecoveryCandidate = isModelUnavailableParkRecoveryEligible(agent, errorRecoveryLimit);
if (
agent.state !== "running"
&& agent.state !== "error"
&& !isReclassifiedRecoverableParkedError(agent)
&& !isModelUnavailableRecoveryCandidate
) {
return false;
}
/*
@@ -10740,7 +10731,11 @@ export class SelfHealingManager {
return false;
}
if (agent.state === "error" || isReclassifiedRecoverableParkedError(agent)) {
if (
agent.state === "error"
|| isReclassifiedRecoverableParkedError(agent)
|| isModelUnavailableRecoveryCandidate
) {
const runtimeConfig = (agent.runtimeConfig ?? {}) as Record<string, unknown>;
if (runtimeConfig.enabled === false) {
return false;
@@ -10748,7 +10743,7 @@ export class SelfHealingManager {
if (this.options.hasActiveAgentExecution?.(agent.id) === true) {
return false;
}
const isRecoverableHeartbeatError = isHeartbeatErrorRecoverable(agent);
const isRecoverableHeartbeatError = isHeartbeatErrorRecoverable(agent) || isModelUnavailableRecoveryCandidate;
const isStaleMissingModule = isStaleWorktreeModuleResolutionError(agent.lastError ?? "");
const isUnrecoverableHeartbeatError = !isRecoverableHeartbeatError && !isStaleMissingModule;
@@ -10781,14 +10776,23 @@ export class SelfHealingManager {
for (const agent of orphaned) {
const updatedAt = Date.parse(agent.updatedAt ?? "");
const stuckForMs = Math.max(0, now - updatedAt);
// Reclassified "error-unrecoverable" parked agents run the same recovery
// branch as error-state agents: shared budget, cooldown, audit, restart.
const isErrorRecoveryCandidate = agent.state === "error" || isReclassifiedRecoverableParkedError(agent);
// Reclassified "error-unrecoverable" and heartbeat-model-unavailable parked
// agents run the same recovery branch as error-state agents: shared budget,
// cooldown, audit, restart.
const isErrorRecoveryCandidate =
agent.state === "error"
|| isReclassifiedRecoverableParkedError(agent)
|| isModelUnavailablePark(agent);
try {
if (isErrorRecoveryCandidate) {
const recoveryState = this.getDurableAgentRecoveryState(agent);
const isStaleMissingModule = isStaleWorktreeModuleResolutionError(agent.lastError ?? "");
const isUnrecoverableHeartbeatError = !isHeartbeatErrorRecoverable(agent) && !isStaleMissingModule;
// Model-unavailable parks are intentionally operator-actionable by lastError text
// but still budget-retryable; do not reclassify them as error-unrecoverable here.
const isUnrecoverableHeartbeatError =
!isModelUnavailablePark(agent)
&& !isHeartbeatErrorRecoverable(agent)
&& !isStaleMissingModule;
if (isUnrecoverableHeartbeatError) {
/*
FNXC:AgentHeartbeat 2026-07-12-18:34:
@@ -10878,8 +10882,12 @@ export class SelfHealingManager {
limit: errorRecoveryLimit,
source: "self-healing",
});
await agentStore.updateAgentState(agent.id, "paused");
await agentStore.updateAgent(agent.id, { pauseReason: HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON });
// Keep heartbeat-model-unavailable labeling when that park exhausted so
// operators still see credential/model guidance rather than a generic label.
if (!isModelUnavailablePark(agent)) {
await agentStore.updateAgentState(agent.id, "paused");
await agentStore.updateAgent(agent.id, { pauseReason: HEARTBEAT_ERROR_RETRY_EXHAUSTED_PAUSE_REASON });
}
log.warn(`Suppressed durable-agent auto-restart for ${agent.id}: retry budget exhausted`);
continue;
}
@@ -10888,8 +10896,8 @@ export class SelfHealingManager {
await agentStore.updateAgentState(agent.id, "active");
await agentStore.updateAgent(agent.id, {
lastError: undefined,
// Clear the "error-unrecoverable" park marker when a reclassified
// parked agent is re-admitted; harmless no-op for error-state agents.
// Clear error-unrecoverable / heartbeat-model-unavailable park markers when
// a parked agent is re-admitted; harmless no-op for bare error-state agents.
pauseReason: undefined,
});