From 1eba42097ba78a6f18ef78ca3022db11ecbd11a7 Mon Sep 17 00:00:00 2001 From: prateek Date: Sat, 14 Feb 2026 16:52:46 +0530 Subject: [PATCH] fix: detect agent exit for all agent types, not just idle-reporting ones (#22) The lifecycle manager only checked isProcessRunning when detectActivity returned "idle". Agents like codex, aider, and opencode return "active" for any non-empty terminal output (including shell prompt after exit), so their exit was never detected. Now checks isProcessRunning for both "idle" and "active" states. Co-authored-by: Claude Opus 4.6 --- .../src/__tests__/lifecycle-manager.test.ts | 29 ++++++++++++++++++- packages/core/src/lifecycle-manager.ts | 14 ++++----- .../src/agent-aider.integration.test.ts | 10 ++++--- .../src/agent-codex.integration.test.ts | 10 ++++--- .../src/agent-opencode.integration.test.ts | 10 ++++--- 5 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 31ab02120..1800d2d55 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -77,7 +77,7 @@ beforeEach(() => { getLaunchCommand: vi.fn(), getEnvironment: vi.fn(), detectActivity: vi.fn().mockReturnValue("active" as ActivityState), - isProcessRunning: vi.fn(), + isProcessRunning: vi.fn().mockResolvedValue(true), isProcessing: vi.fn().mockResolvedValue(false), getSessionInfo: vi.fn().mockResolvedValue(null), }; @@ -232,6 +232,33 @@ describe("check (single session)", () => { expect(lm.getStates().get("app-1")).toBe("killed"); }); + it("detects killed state when agent process exits (active terminal + dead process)", async () => { + // Stub agents (codex, aider, opencode) return "active" for any non-empty + // terminal output, including the shell prompt after the agent exits. + vi.mocked(mockAgent.detectActivity).mockReturnValue("active"); + vi.mocked(mockAgent.isProcessRunning).mockResolvedValue(false); + + const session = makeSession({ status: "working" }); + vi.mocked(mockSessionManager.get).mockResolvedValue(session); + + writeMetadata(dataDir, "app-1", { + worktree: "/tmp", + branch: "main", + status: "working", + project: "my-app", + }); + + const lm = createLifecycleManager({ + config, + registry: mockRegistry, + sessionManager: mockSessionManager, + }); + + await lm.check("app-1"); + + expect(lm.getStates().get("app-1")).toBe("killed"); + }); + it("stays working when agent is idle but process is still running", async () => { vi.mocked(mockAgent.detectActivity).mockReturnValue("idle"); vi.mocked(mockAgent.isProcessRunning).mockResolvedValue(true); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index aebc436bc..b2930dc98 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -203,13 +203,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const activity = agent.detectActivity(terminalOutput); if (activity === "waiting_input") return "needs_input"; - // If the terminal looks idle, check whether the agent process is - // still running. An idle terminal with no agent process means the - // agent has exited (e.g. finished its task or crashed). - if (activity === "idle") { - const processAlive = await agent.isProcessRunning(session.runtimeHandle); - if (!processAlive) return "killed"; - } + // Check whether the agent process is still alive. Some agents + // (codex, aider, opencode) return "active" for any non-empty + // terminal output, including the shell prompt visible after exit. + // Checking isProcessRunning for both "idle" and "active" ensures + // exit detection works regardless of the agent's classifier. + const processAlive = await agent.isProcessRunning(session.runtimeHandle); + if (!processAlive) return "killed"; } } catch { // On probe failure, preserve current stuck/needs_input state rather diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts index 13c72c6ee..4c694d72d 100644 --- a/packages/integration-tests/src/agent-aider.integration.test.ts +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -157,10 +157,12 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { expect(exitedRunning).toBe(false); }); - it("detectActivity → idle after agent exits", () => { - // detectActivity is a pure terminal-text classifier; it returns "idle" - // for empty/shell-prompt output. Process exit is detected by isProcessRunning. - expect(exitedActivity).toBe("idle"); + it("detectActivity → idle or active after agent exits", () => { + // Aider's stub classifier returns "active" for any non-empty output and + // "idle" for empty output. After exit the terminal usually shows a shell + // prompt (non-empty → "active"), but may be empty depending on timing. + // Process exit is detected by isProcessRunning, not detectActivity. + expect(["idle", "active"]).toContain(exitedActivity); }); it("getSessionInfo → null (not implemented for aider)", () => { diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts index b41650a9f..8bf35e1e9 100644 --- a/packages/integration-tests/src/agent-codex.integration.test.ts +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -129,10 +129,12 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { expect(exitedRunning).toBe(false); }); - it("detectActivity → idle after agent exits", () => { - // detectActivity is a pure terminal-text classifier; it returns "idle" - // for empty/shell-prompt output. Process exit is detected by isProcessRunning. - expect(exitedActivity).toBe("idle"); + it("detectActivity → idle or active after agent exits", () => { + // Codex's stub classifier returns "active" for any non-empty output and + // "idle" for empty output. After exit the terminal usually shows a shell + // prompt (non-empty → "active"), but may be empty depending on timing. + // Process exit is detected by isProcessRunning, not detectActivity. + expect(["idle", "active"]).toContain(exitedActivity); }); it("getSessionInfo → null (not implemented for codex)", () => { diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index 6ace3fd38..303a55e04 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -148,10 +148,12 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { expect(exitedRunning).toBe(false); }); - it("detectActivity → idle after agent exits", () => { - // detectActivity is a pure terminal-text classifier; it returns "idle" - // for empty/shell-prompt output. Process exit is detected by isProcessRunning. - expect(exitedActivity).toBe("idle"); + it("detectActivity → idle or active after agent exits", () => { + // OpenCode's stub classifier returns "active" for any non-empty output and + // "idle" for empty output. After exit the terminal usually shows a shell + // prompt (non-empty → "active"), but may be empty depending on timing. + // Process exit is detected by isProcessRunning, not detectActivity. + expect(["idle", "active"]).toContain(exitedActivity); }); it("getSessionInfo → null (not implemented for opencode)", () => {