feat(FN-3580): restore canonical agent lifecycle and remove terminated agen

This merge restores the canonical agent lifecycle with termination scoped at the run level (FN-3580, 4 steps), adds sender-side wake recipient override for messages, and introduces test isolation CI enforcement with a stuck-requeue race fix. UI changes remove terminated-agent indicators from AgentDe

Fusion-Task-Id: FN-3580
This commit is contained in:
Fusion
2026-05-06 10:18:27 -07:00
committed by gsxdsm
parent b3aa9f9890
commit 1ce47fd870
24 changed files with 216 additions and 286 deletions

View File

@@ -132,6 +132,43 @@ describe("AgentStore", () => {
const persisted = await store.getAgent(agent.id);
expect((persisted?.runtimeConfig as Record<string, unknown> | undefined)?.enabled).toBe(false);
});
it("migrates persisted terminated agents to paused once", async () => {
store.close();
store = new AgentStore({ rootDir });
await store.init();
const agent = await store.createAgent({
name: "Legacy Terminated Agent",
role: "executor",
});
await store.updateAgent(agent.id, {
lastError: "legacy stop",
});
const testDb = (store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => unknown; get?: (key: string) => { value?: string } | undefined } } }).db;
testDb.prepare("UPDATE agents SET state = ? WHERE id = ?").run("terminated", agent.id);
testDb.prepare("DELETE FROM __meta WHERE key = ?").run("removeTerminatedAgentState");
store.close();
store = new AgentStore({ rootDir });
await store.init();
const migrated = await store.getAgent(agent.id);
expect(migrated?.state).toBe("paused");
expect(migrated?.pauseReason).toBe("migrated-from-terminated");
expect(migrated?.lastError).toBe("legacy stop");
const metaRow = (store as unknown as { db: { prepare: (sql: string) => { get: (key: string) => { value?: string } | undefined } } }).db
.prepare("SELECT value FROM __meta WHERE key = ?")
.get("removeTerminatedAgentState");
expect(metaRow?.value).toBe("1");
const reopenedDb = (store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => unknown } } }).db;
reopenedDb.prepare("UPDATE agents SET state = ?, data = json_set(COALESCE(data, '{}'), '$.pauseReason', null) WHERE id = ?").run("terminated", agent.id);
await store.init();
const stillTerminated = await store.getAgent(agent.id);
expect(stillTerminated?.state).toBe("terminated");
});
});
// ── createAgent ───────────────────────────────────────────────────
@@ -1525,67 +1562,37 @@ describe("AgentStore", () => {
expect(updated.state).toBe("active");
});
it("active → terminated transition succeeds", async () => {
const agent = await createReadyAgent(store, "ActiveToTerminated");
it("running → paused transition succeeds", async () => {
const agent = await createReadyAgent(store, "RunningToPaused");
await store.updateAgentState(agent.id, "active");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
await store.updateAgentState(agent.id, "running");
const updated = await store.updateAgentState(agent.id, "paused");
expect(updated.state).toBe("paused");
});
it("paused → terminated transition succeeds", async () => {
it("error → active transition succeeds", async () => {
const agent = await createReadyAgent(store, "ErrorToActive");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "error");
const updated = await store.updateAgentState(agent.id, "active");
expect(updated.state).toBe("active");
});
it("rejects active → terminated transition", async () => {
const agent = await createReadyAgent(store, "ActiveToTerminated");
await store.updateAgentState(agent.id, "active");
await expect(
store.updateAgentState(agent.id, "terminated" as never)
).rejects.toThrow("Invalid state transition: active -> terminated");
});
it("rejects paused → terminated transition", async () => {
const agent = await createReadyAgent(store, "PausedToTerminated");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "paused");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
});
it("error → terminated transition succeeds", async () => {
const agent = await createReadyAgent(store, "ErrorToTerminated");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "error");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
});
it("running → terminated transition succeeds", async () => {
const agent = await createReadyAgent(store, "RunningToTerminated");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "running");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
});
it("terminated → idle|active|running transitions succeed", async () => {
const idleAgent = await createReadyAgent(store, "TerminatedToIdle");
await store.updateAgentState(idleAgent.id, "active");
await store.updateAgentState(idleAgent.id, "terminated");
expect((await store.updateAgentState(idleAgent.id, "idle")).state).toBe("idle");
const activeAgent = await createReadyAgent(store, "TerminatedToActive");
await store.updateAgentState(activeAgent.id, "active");
await store.updateAgentState(activeAgent.id, "terminated");
expect((await store.updateAgentState(activeAgent.id, "active")).state).toBe("active");
const runningAgent = await createReadyAgent(store, "TerminatedToRunning");
await store.updateAgentState(runningAgent.id, "active");
await store.updateAgentState(runningAgent.id, "terminated");
expect((await store.updateAgentState(runningAgent.id, "running")).state).toBe("running");
});
it("clears lastError when leaving terminated for actionable states", async () => {
const agent = await createReadyAgent(store, "TerminatedClearsError");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
await store.updateAgent(agent.id, { lastError: "old error" });
const toActive = await store.updateAgentState(agent.id, "active");
expect(toActive.lastError).toBeUndefined();
await store.updateAgentState(agent.id, "terminated");
await store.updateAgent(agent.id, { lastError: "old error again" });
const toRunning = await store.updateAgentState(agent.id, "running");
expect(toRunning.lastError).toBeUndefined();
await expect(
store.updateAgentState(agent.id, "terminated" as never)
).rejects.toThrow("Invalid state transition: paused -> terminated");
});
it("same-state transition returns agent unchanged (no-op)", async () => {
@@ -1893,8 +1900,8 @@ describe("AgentStore", () => {
// ── resetAgent ────────────────────────────────────────────────────
describe("resetAgent", () => {
// Helper: create an agent and transition it to terminated with error/task
async function createTerminatedAgent(s: AgentStore, name: string) {
// Helper: create a paused agent with error/task state to verify reset semantics.
async function createPausedAgent(s: AgentStore, name: string) {
const agent = await s.createAgent({ name, role: "executor" });
await s.recordHeartbeat(agent.id, "ok");
await s.recordHeartbeat(agent.id, "missed");
@@ -1905,12 +1912,11 @@ describe("AgentStore", () => {
lastError: "something broke",
});
await s.updateAgentState(agent.id, "paused");
await s.updateAgentState(agent.id, "terminated");
return agent;
}
it("transitions terminated agent to idle", async () => {
const agent = await createTerminatedAgent(store, "ResetToIdle");
it("transitions paused agent to idle", async () => {
const agent = await createPausedAgent(store, "ResetToIdle");
const reset = await store.resetAgent(agent.id);
expect(reset.state).toBe("idle");
@@ -1935,28 +1941,28 @@ describe("AgentStore", () => {
});
it("clears lastError", async () => {
const agent = await createTerminatedAgent(store, "ResetClearsError");
const agent = await createPausedAgent(store, "ResetClearsError");
const reset = await store.resetAgent(agent.id);
expect(reset.lastError).toBeUndefined();
});
it("clears pauseReason", async () => {
const agent = await createTerminatedAgent(store, "ResetClearsPause");
const agent = await createPausedAgent(store, "ResetClearsPause");
const reset = await store.resetAgent(agent.id);
expect(reset.pauseReason).toBeUndefined();
});
it("clears taskId", async () => {
const agent = await createTerminatedAgent(store, "ResetClearsTask");
const agent = await createPausedAgent(store, "ResetClearsTask");
const reset = await store.resetAgent(agent.id);
expect(reset.taskId).toBeUndefined();
});
it("starts fresh heartbeat tracking on subsequent active transition", async () => {
const agent = await createTerminatedAgent(store, "ResetHeartbeat");
const agent = await createPausedAgent(store, "ResetHeartbeat");
await store.resetAgent(agent.id);
// After reset, explicitly start a heartbeat run (as the caller would)

View File

@@ -262,6 +262,7 @@ export class AgentStore extends EventEmitter {
void this.db;
await mkdir(this.agentsDir, { recursive: true });
await this.importLegacyFileDataOnce();
await this.migrateTerminatedAgentStateOnce();
await this.migrateHeartbeatProcedurePathOnce();
}
@@ -476,6 +477,48 @@ export class AgentStore extends EventEmitter {
this.db.bumpLastModified();
}
/**
* One-shot migration that rewrites legacy `state = "terminated"` agents to
* `state = "paused"` and preserves the origin via
* `pauseReason = "migrated-from-terminated"`.
*
* Heartbeat run rows intentionally keep their independent `terminated`
* terminal status; this migration only normalizes the agent lifecycle state.
*/
private async migrateTerminatedAgentStateOnce(): Promise<void> {
const migrationKey = "removeTerminatedAgentState";
const migrationVersion = "1";
const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as
| { value: string }
| undefined;
if (row?.value === migrationVersion) {
return;
}
const rows = this.db.prepare("SELECT * FROM agents WHERE state = 'terminated'").all() as unknown as AgentRow[];
let migratedCount = 0;
for (const row of rows) {
const agent = this.mapAgentRow(row);
const updated: Agent = {
...agent,
state: "paused",
pauseReason: "migrated-from-terminated",
updatedAt: new Date().toISOString(),
};
await this.writeAgent(updated);
migratedCount += 1;
}
this.db.prepare(`
INSERT INTO __meta (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(migrationKey, migrationVersion);
if (migratedCount > 0) {
this.db.bumpLastModified();
}
}
/**
* Find the first non-ephemeral agent by exact name.
*
@@ -1172,13 +1215,6 @@ export class AgentStore extends EventEmitter {
...agent,
state: newState,
updatedAt: new Date().toISOString(),
// Clear lastError when leaving terminated for an actionable state so
// resumed agents do not carry stale error badges.
...(
currentState === "terminated" &&
(newState === "idle" || newState === "active" || newState === "running") &&
{ lastError: undefined }
),
};
await this.writeAgent(updated);

View File

@@ -3308,17 +3308,16 @@ export interface PlanningSession {
// ── Agent Types ────────────────────────────────────────────────────────────
/** Agent lifecycle states */
export const AGENT_STATES = ["idle", "active", "running", "paused", "error", "terminated"] as const;
export const AGENT_STATES = ["idle", "active", "running", "paused", "error"] as const;
export type AgentState = (typeof AGENT_STATES)[number];
/** Valid state transitions for agents */
export const AGENT_VALID_TRANSITIONS: Record<AgentState, AgentState[]> = {
idle: ["active"],
active: ["idle", "running", "paused", "error", "terminated"],
running: ["idle", "active", "paused", "error", "terminated"],
paused: ["idle", "active", "terminated"],
error: ["idle", "active", "terminated"],
terminated: ["idle", "active", "running"],
active: ["idle", "running", "paused", "error"],
running: ["idle", "active", "paused", "error"],
paused: ["idle", "active"],
error: ["idle", "active"],
};
/**