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