feat(FN-3535): restore terminated agent state and align lifecycle controls

This merge completes the agent terminated state alignment (FN-3535), adding "running → terminated" transition support with consistent styling and lifecycle controls across the heartbeat engine, agent store, and dashboard UI, plus plugin author documentation improvements (FN-3537) and plugin loader t

Fusion-Task-Id: FN-3535
This commit is contained in:
Fusion
2026-05-06 03:01:23 -07:00
committed by gsxdsm
parent b403ae5ce5
commit 4357098440
17 changed files with 304 additions and 35 deletions

View File

@@ -297,6 +297,19 @@ describe("executeHeartbeat", () => {
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
});
it("completes with invalid_state when agent state is terminated", async () => {
const store = createStoreWithAgentForExec({ state: "terminated" });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(result).toBeDefined();
expect(result.status).toBe("completed");
expect(result.resultJson).toEqual({ reason: "invalid_state", state: "terminated" });
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
});
it("completes as failed when agent not found in store", async () => {
const store = createStoreWithAgentForExec();
(store.getAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null);

View File

@@ -734,12 +734,12 @@ describe("HeartbeatTriggerScheduler", () => {
id: agentId,
name: `Agent ${agentId}`,
role: "executor" as const,
state: "paused" as const,
state: "terminated" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
}));
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
eventStore.emit("agent:updated", { id: "agent-001", state: "terminated", metadata: {} } as import("@fusion/core").Agent);
// Timer should be cleared for terminated agents
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");

View File

@@ -572,7 +572,7 @@ describe("Budget Governance", () => {
});
expect(store.getBudgetStatus).not.toHaveBeenCalled();
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
});

View File

@@ -891,7 +891,7 @@ export class HeartbeatMonitor {
await this.store.updateAgentState(agentId, "error");
await this.store.updateAgent(agentId, { lastError: completionResult.stderrExcerpt ?? "Run failed" });
} else if (completionResult.status === "terminated") {
await this.store.updateAgentState(agentId, "paused");
await this.store.updateAgentState(agentId, "terminated");
} else {
// Completed successfully - back to active
await this.store.updateAgentState(agentId, "active");
@@ -2518,9 +2518,9 @@ const OVERDUE_FIRE_JITTER_MS = 5_000;
* - "idle" — Agent is between tasks, waiting for work (FN-2289 fix)
*
* States where timers should be cleared:
* - "paused" — Agent halted (manual stop, run terminated, child cleanup)
* - "error" — Agent encountered an error
* - "paused" — Agent is paused by budget exhaustion or manual action
* - "error" — Agent encountered an error
* - "terminated" — Agent was explicitly stopped/terminated
*/
function isTickableState(state: Agent["state"]): boolean {
return state === "active" || state === "running" || state === "idle";