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 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-14 16:52:46 +05:30 committed by GitHub
parent 7ae6ab17a2
commit 1eba42097b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 53 additions and 20 deletions

View File

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

View File

@ -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

View File

@ -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)", () => {

View File

@ -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)", () => {

View File

@ -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)", () => {