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 7f90308485
commit a31c4323a8
24 changed files with 216 additions and 286 deletions

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);