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:
@@ -16,11 +16,10 @@ vi.mock("@fusion/core", () => ({
|
||||
})),
|
||||
AGENT_VALID_TRANSITIONS: {
|
||||
idle: ["active"],
|
||||
active: ["running", "paused", "terminated"],
|
||||
running: ["active", "paused", "error", "terminated"],
|
||||
paused: ["active", "terminated"],
|
||||
error: ["active", "terminated"],
|
||||
terminated: ["idle", "active", "running"],
|
||||
active: ["running", "paused"],
|
||||
running: ["active", "paused", "error"],
|
||||
paused: ["active"],
|
||||
error: ["active"],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -124,14 +123,6 @@ describe("runAgentStop", () => {
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("should reject stopping a terminated agent (invalid transition)", async () => {
|
||||
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
|
||||
|
||||
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runAgentStart", () => {
|
||||
@@ -152,16 +143,6 @@ describe("runAgentStart", () => {
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
|
||||
});
|
||||
|
||||
it("should start a terminated agent", async () => {
|
||||
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
|
||||
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
|
||||
|
||||
await runAgentStart("agent-test123");
|
||||
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
|
||||
});
|
||||
|
||||
it("should start an idle agent", async () => {
|
||||
mockGetAgent.mockResolvedValue(makeAgent("idle"));
|
||||
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
|
||||
|
||||
@@ -52,14 +52,12 @@ async function git(command: string, cwd: string): Promise<string> {
|
||||
describe("init command", () => {
|
||||
let tempProjectDir: string;
|
||||
let tempHomeDir: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
const isolatedHome = process.env.HOME;
|
||||
const isolatedUserProfile = process.env.USERPROFILE;
|
||||
|
||||
beforeEach(() => {
|
||||
tempProjectDir = tempDir("fn-init-test-");
|
||||
tempHomeDir = tempDir("fn-init-home-");
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = tempHomeDir;
|
||||
process.env.USERPROFILE = tempHomeDir;
|
||||
mockCentralInit.mockResolvedValue(undefined);
|
||||
@@ -81,15 +79,15 @@ describe("init command", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) {
|
||||
if (isolatedHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
process.env.HOME = isolatedHome;
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
if (isolatedUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
process.env.USERPROFILE = isolatedUserProfile;
|
||||
}
|
||||
|
||||
if (existsSync(tempProjectDir)) {
|
||||
|
||||
@@ -2227,7 +2227,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
promptGuidelines: [
|
||||
"Use to pause an agent that is currently running or active",
|
||||
"Stopped agents can be resumed with fn_agent_start",
|
||||
"Agents in 'idle', 'error', or 'terminated' state cannot be stopped",
|
||||
"Agents in 'idle', 'error', or already-paused state cannot be stopped",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Agent ID to stop (e.g., agent-abc123)" }),
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -259,15 +259,15 @@ Manage AI agents with a dedicated control surface accessible from the main dashb
|
||||
**Features**:
|
||||
- **Agent-first layout**: The main agent collection (list/board/tree/org) renders first, with summary sections (metrics + active/live panel) below it.
|
||||
- **Controls Popup**: Import, state filter, Show system agents toggle, and global Heartbeat Speed are grouped under a compact `Controls` trigger (`aria-haspopup`, `aria-expanded`, Escape/outside-click dismissal).
|
||||
- **State Filter**: Styled dropdown to filter agents by state (All States, Idle, Active, Paused, Terminated) with Filter icon, aria-label, and consistent dashboard styling using design tokens (`--radius-sm`, `--border`, `--bg`, `--focus-ring`)
|
||||
- **Terminated Agent Filtering**: By default ("All States" filter), terminated agents are automatically hidden from the agent list to reduce clutter from frequently-terminating runtime task-worker agents. Terminated agents remain accessible by explicitly selecting the "Terminated" filter option, enabling intentional inspection and cleanup when needed. This behavior applies to both the main AgentsView and the AgentListModal.
|
||||
- **State Filter**: Styled dropdown to filter agents by state (All States, Idle, Active, Running, Paused, Error) with Filter icon, aria-label, and consistent dashboard styling using design tokens (`--radius-sm`, `--border`, `--bg`, `--focus-ring`)
|
||||
- **All States Behavior**: The default filter shows all durable agents, including paused and error agents, so stopped/problem agents stay visible without a dedicated terminated bucket. This behavior applies to both the main AgentsView and the AgentListModal.
|
||||
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
|
||||
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and terminated agents (active and paused agents must be stopped/terminated first)
|
||||
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated) using CSS variable references for theme consistency
|
||||
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and paused agents
|
||||
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Running, Error) using CSS variable references for theme consistency
|
||||
- **Agent Error Details**: Agent collection views now show a compact inline error indicator (instead of raw stack traces) that opens a shared error-details modal with full text, copy action, and a prefilled "Report on GitHub" shortcut
|
||||
- **Agent Detail**: Click any agent card to open a detail modal with full agent information. In list view, each agent card also provides an explicit **View Details** action button in the card actions row for clearer discoverability, while the existing clickable identity/header area remains supported. The modal features a compact header with clear visual hierarchy:
|
||||
- **Identity area** (left): Agent icon, name, and state/health badges
|
||||
- **Lifecycle controls** (center): Compact action buttons for state transitions (Start, Pause, Resume, Retry, Stop, Delete)
|
||||
- **Lifecycle controls** (center): Compact action buttons for state transitions (Start, Pause, Resume, Retry, Stop→Paused, Delete)
|
||||
- **Utility actions** (right): Refresh and Close buttons
|
||||
- The compact layout reduces vertical footprint while maintaining all agent-state actions
|
||||
- The **Settings** tab includes **editable advanced settings** (heartbeat interval, max retries, task timeout, log level) persisted through `agent.metadata`. Empty fields revert to system defaults, invalid values block save with inline error messages
|
||||
|
||||
@@ -93,7 +93,6 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
|
||||
active: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
running: { bg: "var(--state-active-bg)", text: "var(--state-active-text)", border: "var(--state-active-border)" },
|
||||
paused: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
terminated: { bg: "var(--state-paused-bg)", text: "var(--state-paused-text)", border: "var(--state-paused-border)" },
|
||||
error: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
@@ -563,7 +562,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Pause size={14} />
|
||||
<span className="agent-detail-control-label">Pause</span>
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
@@ -575,10 +574,6 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Play size={14} />
|
||||
<span className="agent-detail-control-label">Resume</span>
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
@@ -591,7 +586,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Pause size={14} />
|
||||
<span className="agent-detail-control-label">Pause</span>
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
@@ -603,24 +598,12 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
<Play size={14} />
|
||||
Retry
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("terminated")} disabled={isTransitioning}>
|
||||
<button className="btn btn--danger btn--compact" onClick={() => void handleStateChange("paused")} disabled={isTransitioning}>
|
||||
<Square size={14} />
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button className="btn btn-task-create btn--compact" onClick={() => void handleStateChange("active")} disabled={isTransitioning}>
|
||||
<Play size={14} />
|
||||
Reactivate
|
||||
</button>
|
||||
<button className="btn btn--danger btn--compact" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Utility actions: refresh + close */}
|
||||
|
||||
@@ -300,7 +300,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -406,7 +405,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -424,14 +423,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
@@ -453,7 +444,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -473,7 +464,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -481,25 +472,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Reactivate"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -626,7 +598,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -644,14 +616,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
>
|
||||
<Play size={14} /> Resume
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
@@ -673,7 +637,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -693,7 +657,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleStateChange(agent.id, "terminated")}
|
||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Stop"
|
||||
>
|
||||
@@ -701,25 +665,6 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{agent.state === "terminated" && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn--sm"
|
||||
onClick={() => void handleStateChange(agent.id, "active")}
|
||||
disabled={transitioningAgentIds.has(agent.id)}
|
||||
title="Reactivate"
|
||||
>
|
||||
<Play size={14} /> Reactivate
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--sm btn--danger"
|
||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -758,8 +758,7 @@
|
||||
border: 1px solid var(--state-idle-border);
|
||||
}
|
||||
|
||||
.agent-badge--error,
|
||||
.agent-badge--terminated {
|
||||
.agent-badge--error {
|
||||
background: var(--state-error-bg);
|
||||
color: var(--state-error-text);
|
||||
border: 1px solid var(--state-error-border);
|
||||
@@ -772,8 +771,7 @@
|
||||
border-top-color: var(--state-active-border);
|
||||
background: var(--state-active-bg);
|
||||
}
|
||||
.agent-board-card--error,
|
||||
.agent-board-card--terminated { border-top-color: var(--state-error-border); }
|
||||
.agent-board-card--error { border-top-color: var(--state-error-border); }
|
||||
|
||||
.agent-card--idle,
|
||||
.agent-card--active,
|
||||
@@ -786,8 +784,7 @@
|
||||
.agent-card--running:hover {
|
||||
background: color-mix(in srgb, var(--state-active-border) 20%, var(--card-hover));
|
||||
}
|
||||
.agent-card--error,
|
||||
.agent-card--terminated { border-left-color: var(--state-error-border); }
|
||||
.agent-card--error { border-left-color: var(--state-error-border); }
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -60,8 +60,6 @@ function getStateBadgeClass(state: AgentState): string {
|
||||
return "agent-badge--paused";
|
||||
case "error":
|
||||
return "agent-badge--error";
|
||||
case "terminated":
|
||||
return "agent-badge--terminated";
|
||||
case "idle":
|
||||
default:
|
||||
return "agent-badge--idle";
|
||||
@@ -81,8 +79,6 @@ function getStateCardClass(
|
||||
return `${prefix}--paused`;
|
||||
case "error":
|
||||
return `${prefix}--error`;
|
||||
case "terminated":
|
||||
return `${prefix}--terminated`;
|
||||
case "idle":
|
||||
default:
|
||||
return `${prefix}--idle`;
|
||||
@@ -298,7 +294,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
|
||||
// Filter agents for display. "All States" means all non-ephemeral agents,
|
||||
// including disabled/terminated agents that still carry configuration.
|
||||
// including paused/error agents and heartbeat-disabled agents that still carry configuration.
|
||||
// When "Show system agents" is enabled, include ephemeral/internal agents.
|
||||
const displayAgents = useMemo(() => {
|
||||
return optimisticAgents.filter((agent) => showSystemAgents || !isEphemeralAgent(agent));
|
||||
@@ -850,7 +846,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -879,7 +879,7 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("transitions running agent to terminated when Stop is clicked", async () => {
|
||||
it("transitions running agent to paused when Stop is clicked", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "running" }));
|
||||
|
||||
render(
|
||||
@@ -893,7 +893,7 @@ describe("AgentDetailView", () => {
|
||||
await userEvent.click(await screen.findByText("Stop"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "terminated", undefined);
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -914,7 +914,7 @@ describe("AgentDetailView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("transitions error agent to terminated when Stop is clicked", async () => {
|
||||
it("transitions error agent to paused when Stop is clicked", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "error" }));
|
||||
|
||||
render(
|
||||
@@ -928,7 +928,7 @@ describe("AgentDetailView", () => {
|
||||
await userEvent.click(await screen.findByText("Stop"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "terminated", undefined);
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-001", "paused", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ describe("AgentListModal", () => {
|
||||
fireEvent.click(stopButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "terminated", undefined);
|
||||
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-002", "paused", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -257,12 +257,12 @@ describe("agent modal mobile CSS structure", () => {
|
||||
expect(within(controls).getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows terminated controls for terminated agents", async () => {
|
||||
it("shows paused controls for paused agents", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce({
|
||||
id: "agent-terminated",
|
||||
name: "Terminated Agent",
|
||||
id: "agent-paused",
|
||||
name: "Paused Agent",
|
||||
role: "executor",
|
||||
state: "terminated",
|
||||
state: "paused",
|
||||
taskId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -273,7 +273,7 @@ describe("agent modal mobile CSS structure", () => {
|
||||
completedRuns: [],
|
||||
} as any);
|
||||
|
||||
render(<AgentDetailView agentId="agent-terminated" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
render(<AgentDetailView agentId="agent-paused" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
const controls = await waitFor(() => {
|
||||
const node = document.querySelector(".agent-detail-controls");
|
||||
@@ -281,7 +281,7 @@ describe("agent modal mobile CSS structure", () => {
|
||||
return node as HTMLElement;
|
||||
});
|
||||
|
||||
expect(within(controls).getByRole("button", { name: "Reactivate" })).toBeInTheDocument();
|
||||
expect(within(controls).getByRole("button", { name: "Resume" })).toBeInTheDocument();
|
||||
expect(within(controls).getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe("AgentsView mobile adaptations", () => {
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
const optionValues = Array.from(select.options).map((option) => option.value);
|
||||
expect(optionValues).toEqual(["all", "idle", "active", "running", "paused", "error", "terminated"]);
|
||||
expect(optionValues).toEqual(["all", "idle", "active", "running", "paused", "error"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,16 +36,6 @@ describe("getAgentHealthStatus", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("terminated state", () => {
|
||||
it('returns "Terminated" for terminated agents', () => {
|
||||
const agent = makeAgent({ state: "terminated" });
|
||||
const status = getAgentHealthStatus(agent);
|
||||
expect(status.label).toBe("Terminated");
|
||||
expect(status.stateDerived).toBe(true);
|
||||
expect(status.color).toBe("var(--state-paused-text)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
it('returns "Error" for error agents without lastError', () => {
|
||||
const agent = makeAgent({ state: "error" });
|
||||
@@ -341,12 +331,6 @@ describe("getAgentHealthStatus", () => {
|
||||
expectedLabel: "Running",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "terminated",
|
||||
agent: makeAgent({ state: "terminated" }),
|
||||
expectedLabel: "Terminated",
|
||||
expectedStateDerived: true,
|
||||
},
|
||||
{
|
||||
name: "error without lastError",
|
||||
agent: makeAgent({ state: "error" }),
|
||||
@@ -445,7 +429,6 @@ describe("getAgentHealthStatus", () => {
|
||||
{ agent: makeAgent({ state: "error" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
|
||||
{ agent: makeAgent({ state: "terminated" }), expectedIconType: "Pause" },
|
||||
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
|
||||
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Pause" },
|
||||
{
|
||||
@@ -541,7 +524,6 @@ describe("AgentHealthStatus reason field", () => {
|
||||
{ name: "error", agent: makeAgent({ state: "error" }) },
|
||||
{ name: "paused", agent: makeAgent({ state: "paused" }) },
|
||||
{ name: "running", agent: makeAgent({ state: "running" }) },
|
||||
{ name: "terminated", agent: makeAgent({ state: "terminated" }) },
|
||||
{ name: "idle", agent: makeAgent({ state: "idle" }) },
|
||||
{
|
||||
name: "healthy",
|
||||
|
||||
@@ -94,7 +94,6 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean {
|
||||
* state, runtimeConfig, and last heartbeat timestamp.
|
||||
*
|
||||
* Health labels (in priority order):
|
||||
* - "Terminated" — agent.state === "terminated"
|
||||
* - "Error" — agent.state === "error" (uses lastError if available)
|
||||
* - "Paused" — agent.state === "paused" (uses pauseReason if available)
|
||||
* - "Running" — agent.state === "running", or a detected task worker in "active"
|
||||
@@ -112,16 +111,7 @@ export function getAgentHealthStatus(agent: AgentHealthInput): AgentHealthStatus
|
||||
const isTaskWorker = isTaskWorkerAgent(agent);
|
||||
const isHeartbeatEnabled = isTaskWorker || runtimeConfig?.enabled !== false;
|
||||
|
||||
// Terminal states - these always take precedence
|
||||
if (state === "terminated") {
|
||||
return {
|
||||
label: "Terminated",
|
||||
icon: <Pause size={14} />,
|
||||
color: "var(--state-paused-text)",
|
||||
stateDerived: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit non-running states always take precedence.
|
||||
if (state === "error") {
|
||||
return {
|
||||
label: lastError ?? "Error",
|
||||
|
||||
@@ -297,19 +297,34 @@ 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" });
|
||||
it("completes with invalid_state when agent state is error", async () => {
|
||||
const store = createStoreWithAgentForExec({ state: "error" });
|
||||
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(result.resultJson).toEqual({ reason: "invalid_state", state: "error" });
|
||||
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||||
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
||||
});
|
||||
|
||||
it("keeps terminated as a run status while pausing the agent", async () => {
|
||||
const store = createStoreWithAgentForExec({ state: "running" });
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const run = await monitor.startRun("agent-001", { source: "on_demand" });
|
||||
|
||||
await monitor.completeRun("agent-001", run.id, {
|
||||
status: "terminated",
|
||||
stderrExcerpt: "Run stopped by user",
|
||||
});
|
||||
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "running");
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
|
||||
expect(store.endHeartbeatRun).toHaveBeenCalledWith(run.id, "terminated");
|
||||
});
|
||||
|
||||
it("completes as failed when agent not found in store", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
(store.getAgent as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
|
||||
@@ -725,23 +725,23 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("timer is unregistered when agent becomes terminated (should clear timer)", async () => {
|
||||
it("timer is unregistered when agent becomes paused (should clear timer)", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
expect(scheduler.getRegisteredAgents()).toContain("agent-001");
|
||||
|
||||
// Update to terminated state
|
||||
// Update to paused state
|
||||
(eventStore.getAgent as ReturnType<typeof vi.fn>).mockImplementation((agentId: string) => ({
|
||||
id: agentId,
|
||||
name: `Agent ${agentId}`,
|
||||
role: "executor" as const,
|
||||
state: "terminated" as const,
|
||||
state: "paused" 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: "terminated", metadata: {} } as import("@fusion/core").Agent);
|
||||
eventStore.emit("agent:updated", { id: "agent-001", state: "paused", metadata: {} } as import("@fusion/core").Agent);
|
||||
|
||||
// Timer should be cleared for terminated agents
|
||||
// Timer should be cleared for paused agents
|
||||
expect(scheduler.getRegisteredAgents()).not.toContain("agent-001");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
@@ -560,7 +560,7 @@ describe("Budget Governance", () => {
|
||||
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
|
||||
});
|
||||
|
||||
it("does not pause agent when run is terminated", async () => {
|
||||
it("keeps terminated as a run status while pausing the agent", async () => {
|
||||
const store = createCompleteRunBudgetStore({
|
||||
budgetStatus: createBudgetStatus({ isOverBudget: true, isOverThreshold: true }),
|
||||
});
|
||||
@@ -572,7 +572,7 @@ describe("Budget Governance", () => {
|
||||
});
|
||||
|
||||
expect(store.getBudgetStatus).not.toHaveBeenCalled();
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "terminated");
|
||||
expect(store.updateAgentState).toHaveBeenCalledWith("agent-001", "paused");
|
||||
expect(store.updateAgent).not.toHaveBeenCalledWith("agent-001", { pauseReason: "budget-exhausted" });
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* Callback pattern (not EventEmitter):
|
||||
* - onMissed: Called when an agent misses its heartbeat
|
||||
* - onRecovered: Called when an agent recovers after a missed heartbeat
|
||||
* - onTerminated: Called when an unresponsive agent is terminated
|
||||
* - onTerminated: Called when a heartbeat run is terminated
|
||||
*/
|
||||
|
||||
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore } from "@fusion/core";
|
||||
@@ -69,7 +69,7 @@ export interface HeartbeatMonitorOptions {
|
||||
onMissed?: (agentId: string, reason: string) => void;
|
||||
/** Callback when an agent recovers after a missed heartbeat */
|
||||
onRecovered?: (agentId: string) => void;
|
||||
/** Callback when an unresponsive agent is terminated */
|
||||
/** Callback when a heartbeat run is terminated (run status only; agent state is handled separately). */
|
||||
onTerminated?: (agentId: string, reason: string) => void;
|
||||
/** Callback when a run starts */
|
||||
onRunStarted?: (agentId: string, run: AgentHeartbeatRun) => void;
|
||||
@@ -902,7 +902,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, "terminated");
|
||||
await this.store.updateAgentState(agentId, "paused");
|
||||
} else {
|
||||
// Completed successfully - back to active
|
||||
await this.store.updateAgentState(agentId, "active");
|
||||
@@ -2543,7 +2543,6 @@ const OVERDUE_FIRE_JITTER_MS = 5_000;
|
||||
* States where timers should be cleared:
|
||||
* - "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";
|
||||
|
||||
@@ -1258,8 +1258,8 @@ describe("InProcessRuntime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ephemeral termination cleanup", () => {
|
||||
it("auto-deletes ephemeral agent when it transitions to terminated via agent:stateChanged", async () => {
|
||||
describe("ephemeral paused-state cleanup", () => {
|
||||
it("auto-deletes ephemeral agent when it transitions to paused via agent:stateChanged", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
@@ -1284,7 +1284,7 @@ describe("InProcessRuntime", () => {
|
||||
let agents = await store.listAgents({ includeEphemeral: true });
|
||||
expect(agents.some((a: Agent) => a.id === agent.id)).toBe(true);
|
||||
|
||||
// Emit agent:stateChanged event to trigger termination
|
||||
// Emit agent:stateChanged event to trigger cleanup
|
||||
store.emit("agent:stateChanged", agent.id, "running", "paused");
|
||||
|
||||
// Wait for async handler
|
||||
@@ -1302,7 +1302,7 @@ describe("InProcessRuntime", () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("does not auto-delete non-ephemeral agent when it transitions to terminated", async () => {
|
||||
it("does not auto-delete non-ephemeral agent when it transitions to paused", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
@@ -1323,7 +1323,7 @@ describe("InProcessRuntime", () => {
|
||||
let agents = await store.listAgents();
|
||||
expect(agents.some((a: Agent) => a.id === agent.id)).toBe(true);
|
||||
|
||||
// Emit agent:stateChanged event to trigger termination
|
||||
// Emit agent:stateChanged event to trigger cleanup
|
||||
store.emit("agent:stateChanged", agent.id, "active", "paused");
|
||||
|
||||
// Wait for async handler
|
||||
@@ -1593,12 +1593,12 @@ describe("InProcessRuntime", () => {
|
||||
});
|
||||
|
||||
describe("startup ephemeral sweep", () => {
|
||||
it("cleans terminated ephemeral agents on startup", async () => {
|
||||
it("cleans paused ephemeral agents on startup", async () => {
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const preStore = new AgentStore({ rootDir: join(testDir, ".fusion") });
|
||||
await preStore.init();
|
||||
const orphan = await preStore.createAgent({
|
||||
name: "orphan-terminated",
|
||||
name: "orphan-paused",
|
||||
role: "executor",
|
||||
metadata: { agentKind: "task-worker" },
|
||||
runtimeConfig: { enabled: false },
|
||||
|
||||
Reference in New Issue
Block a user