feat(FN-3630): document lastError recovery semantics in agents.md

Documents `lastError` recovery semantics in the agents reference, clarifying how the system handles and recovers from error states.

Fusion-Task-Id: FN-3630
This commit is contained in:
Fusion
2026-05-07 02:03:24 -07:00
committed by gsxdsm
parent bb3cd7e7b0
commit 415231bd97
3 changed files with 25 additions and 3 deletions

View File

@@ -49,7 +49,7 @@ These fields are managed by the engine and cannot be directly edited:
- `taskId` — Current working task (managed by scheduler) - `taskId` — Current working task (managed by scheduler)
- `totalInputTokens` / `totalOutputTokens` — Token usage totals (managed by engine) - `totalInputTokens` / `totalOutputTokens` — Token usage totals (managed by engine)
- `createdAt` / `updatedAt` / `lastHeartbeatAt` — Timestamps (managed by system) - `createdAt` / `updatedAt` / `lastHeartbeatAt` — Timestamps (managed by system)
- `lastError` — Last error message (managed by engine) - `lastError` — Last error message (managed by engine; cleared after successful recovery runs)
- `pauseReason` — Reason for paused state (managed by engine) - `pauseReason` — Reason for paused state (managed by engine)
### Stale Task Link Sanitization ### Stale Task Link Sanitization
@@ -80,7 +80,7 @@ The `taskId` field is suppressed in API responses when the linked task is in a t
These fields can only be set during update (not on create): These fields can only be set during update (not on create):
- `pauseReason` — Why the agent is paused - `pauseReason` — Why the agent is paused
- `lastError` — Last error message - `lastError` — Last error message (cleared when the agent successfully recovers)
- `totalInputTokens` — Accumulated input token count - `totalInputTokens` — Accumulated input token count
- `totalOutputTokens` — Accumulated output token count - `totalOutputTokens` — Accumulated output token count

View File

@@ -325,6 +325,27 @@ describe("executeHeartbeat", () => {
expect(store.endHeartbeatRun).toHaveBeenCalledWith(run.id, "terminated"); expect(store.endHeartbeatRun).toHaveBeenCalledWith(run.id, "terminated");
}); });
it("clears stale lastError after a subsequent successful heartbeat run", async () => {
const store = createStoreWithAgentForExec({ state: "running" });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const failedRun = await monitor.startRun("agent-001", { source: "on_demand" });
await monitor.completeRun("agent-001", failedRun.id, {
status: "failed",
stderrExcerpt: "Prompt failed",
});
const successfulRun = await monitor.startRun("agent-001", { source: "on_demand" });
await monitor.completeRun("agent-001", successfulRun.id, {
status: "completed",
});
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "error");
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", { lastError: "Prompt failed" });
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "active");
expect(store.updateAgent).toHaveBeenCalledWith("agent-001", { lastError: undefined });
});
it("completes as failed when agent not found in store", async () => { it("completes as failed when agent not found in store", async () => {
const store = createStoreWithAgentForExec(); const store = createStoreWithAgentForExec();
(store.getAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null); (store.getAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null);

View File

@@ -904,8 +904,9 @@ export class HeartbeatMonitor {
} else if (completionResult.status === "terminated") { } else if (completionResult.status === "terminated") {
await this.store.updateAgentState(agentId, "paused"); await this.store.updateAgentState(agentId, "paused");
} else { } else {
// Completed successfully - back to active // Completed successfully - back to active and clear any stale failure marker.
await this.store.updateAgentState(agentId, "active"); await this.store.updateAgentState(agentId, "active");
await this.store.updateAgent(agentId, { lastError: undefined });
} }
} catch (stateTransErr) { } catch (stateTransErr) {
heartbeatLog.warn(`Agent ${agentId} state transition failed: ${stateTransErr instanceof Error ? stateTransErr.message : String(stateTransErr)} — continuing`); heartbeatLog.warn(`Agent ${agentId} state transition failed: ${stateTransErr instanceof Error ? stateTransErr.message : String(stateTransErr)} — continuing`);