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:
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user