feat(FN-3922): enforce task skip on credential-failure heartbeat

Credential-missing recovery is now documented and enforced in the agent heartbeat layer, with comprehensive test coverage for both timer-triggered and assignment-triggered credential failure scenarios.

Fusion-Task-Id: FN-3922
This commit is contained in:
Fusion
2026-05-10 05:43:00 -07:00
committed by gsxdsm
parent 63286c29d0
commit 1e7771e247
4 changed files with 169 additions and 27 deletions

View File

@@ -2545,11 +2545,84 @@ describe("executeHeartbeat", () => {
expect(result.resultJson).toMatchObject({
reason: "heartbeat_model_unavailable",
source: "timer",
detail: expect.stringContaining("No API key for provider: anthropic"),
});
expect(result.stderrExcerpt).toContain("No API key for provider: anthropic");
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
});
it.each(["on_demand", "assignment"] as const)("pauses on %s heartbeat when model provider credentials are unavailable", async (source) => {
const store = createStoreWithAgentForExec();
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source });
expect(result.status).toBe("completed");
expect(result.resultJson).toMatchObject({
reason: "heartbeat_model_unavailable",
source,
actionRequired: true,
detail: expect.stringContaining("Configure credentials for provider \"anthropic\""),
});
expect(result.stderrExcerpt).toContain("No API key for provider: anthropic");
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", {
pauseReason: "heartbeat-model-unavailable",
lastError: expect.stringContaining("No API key for provider: anthropic"),
});
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
});
it("keeps timer-triggered credential failures in recoverable state across consecutive wakeups", async () => {
const store = createStoreWithAgentForExec();
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const first = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const second = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
for (const run of [first, second]) {
expect(run.status).toBe("completed");
expect(run.resultJson).toMatchObject({
reason: "heartbeat_model_unavailable",
source: "timer",
detail: expect.stringContaining("No API key for provider: anthropic"),
});
expect(run.stderrExcerpt).toContain("No API key for provider: anthropic");
}
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
});
it("keeps non-timer credential failures recoverable on consecutive wakeups", async () => {
const store = createStoreWithAgentForExec();
mockedCreateFnAgent.mockRejectedValue(new Error("No API key for provider: anthropic"));
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const first = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
const second = await monitor.executeHeartbeat({ agentId: "agent-001", source: "assignment" });
expect(first.status).toBe("completed");
expect(first.resultJson).toMatchObject({
reason: "heartbeat_model_unavailable",
source: "assignment",
actionRequired: true,
});
expect(second.status).toBe("completed");
expect(second.resultJson).toMatchObject({
reason: "heartbeat_model_unavailable",
source: "assignment",
actionRequired: true,
});
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "error");
});
it("completes run as failed when promptWithFallback throws", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();