From 345fea565346f9976d7a42242d06436feb9295b6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 4 Oct 2026 23:28:40 -0700 Subject: [PATCH] fix(subagent): read shutdown-with-report lanes as done, not cancelled --- src/subagent/lifecycle.test.ts | 22 ++++++++++++++++++++++ src/subagent/lifecycle.ts | 11 ++++++++++- src/tui/chrome-state.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/subagent/lifecycle.test.ts b/src/subagent/lifecycle.test.ts index e04928388..f0728c6be 100644 --- a/src/subagent/lifecycle.test.ts +++ b/src/subagent/lifecycle.test.ts @@ -22,12 +22,34 @@ describe("WorkerLifecycle projections", () => { [{ state: "failed", error: "boom" }, "failed"], [{ state: "cancelled", error: "stop" }, "cancelled"], [{ state: "shutdown" }, "cancelled"], + [{ state: "shutdown", report: "ok" }, "done"], ]; for (const [lifecycle, status] of cases) { expect(projectStripStatus(lifecycle)).toBe(status); } }); + test("shutdown after completion reads done; a close error reads cancelled even with a salvage report", () => { + // CL-9924: close_agent after a completed report must not read as + // cancelled. Report presence alone is not enough — an interrupted run's + // salvage attaches via attachReport, so a shutdown that was closed + // mid-run/interrupted carries the close error and must stay cancelled. + expect(projectStripStatus({ state: "shutdown", report: "ok" })).toBe( + "done", + ); + expect( + projectStripStatus({ + state: "shutdown", + report: "salvage", + error: "Closed by close_agent", + }), + ).toBe("cancelled"); + expect( + projectStripStatus({ state: "shutdown", error: "Closed by close_agent" }), + ).toBe("cancelled"); + expect(projectStripStatus({ state: "shutdown" })).toBe("cancelled"); + }); + test("verb lifecycleStatus does not leak cancelled or failed", () => { expect(projectLifecycleStatus({ state: "pending_init" })).toBe( "pending_init", diff --git a/src/subagent/lifecycle.ts b/src/subagent/lifecycle.ts index 8f940e841..0e51f0ebb 100644 --- a/src/subagent/lifecycle.ts +++ b/src/subagent/lifecycle.ts @@ -35,8 +35,17 @@ export function projectStripStatus(lifecycle: WorkerLifecycle): StripStatus { case "failed": return "failed"; case "cancelled": - case "shutdown": return "cancelled"; + case "shutdown": + // CL-9924: a shutdown that carries a completed report and no close + // error was closed after finishing — read it as done. A close error + // means the lane was closed mid-run / after interrupt / after cancel + // (closeOne stamps "Closed by close_agent" on live shutdowns, and + // attachReport's salvage never removes it), so report presence alone + // is not enough: interrupted-close salvage would otherwise read done. + return lifecycle.report !== undefined && lifecycle.error === undefined + ? "done" + : "cancelled"; } } diff --git a/src/tui/chrome-state.test.ts b/src/tui/chrome-state.test.ts index e13a43e8a..f935d52d1 100644 --- a/src/tui/chrome-state.test.ts +++ b/src/tui/chrome-state.test.ts @@ -348,6 +348,34 @@ describe("formatAgentsPanel", () => { ]); }); + test("a done-mapped shutdown lane paints done, not cancelled", () => { + // CL-9924: close_agent after a completed report projects strip status + // "done" (via projectStripStatus on the stored shutdown lifecycle); the + // terminal row must paint that as " · done", never " · cancelled". + const rows = formatAgentsPanel( + [ + { + agentId: "a", + description: "reported then closed", + status: "done", + currentToolStartedAt: null, + finishedAt: NOW - 1_000, + }, + ], + undefined, + NOW, + ); + expect(rows).toEqual([ + { + label: "● a reported then closed", + tail: " · done", + stalled: false, + kind: "lane", + status: "done", + }, + ]); + }); + test("linger expires — terminal rows drop after the window", () => { expect( formatAgentsPanel(