From fc1c91d39236748b24c64eecdffc38d2d650752c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 13:45:26 -0400 Subject: [PATCH] fix(core): preserve stopped shell outcomes through replay --- packages/client/src/solid/data.ts | 2 +- packages/client/test/solid-data.test.ts | 8 +++- packages/core/src/session/message-updater.ts | 2 +- packages/core/src/session/session.ts | 12 +++--- packages/core/src/shell.ts | 8 +++- packages/core/test/session-shell.test.ts | 21 ++++++++++ packages/tui/src/context/session-tabs.tsx | 14 ++++++- .../tui/test/context/session-tabs.test.tsx | 41 +++++++++++++++++++ 8 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index cb0a5b2d283..f802463fe8a 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -768,7 +768,7 @@ export function createData(config: CreateDataInput) { match.status = event.data.shell.status match.exit = event.data.shell.exit match.output = event.data.output - if (event.metadata) match.metadata = { ...match.metadata, ...event.metadata } + if (event.data.shell.metadata.reason === "user") match.metadata = { ...match.metadata, reason: "user" } match.time.completed = event.created }) return diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index 665702d1ac2..676fa50c6fe 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -754,10 +754,14 @@ test("projects user shell lifecycle metadata", () => { created: 2, type: "session.shell.ended", durable: { aggregateID: "ses_refresh", seq: 2, version: 1 }, - metadata: { reason: "user" }, data: { sessionID: "ses_refresh", - shell: { ...shell, status: "killed", time: { started: 1, completed: 2 } }, + shell: { + ...shell, + status: "killed", + metadata: { ...shell.metadata, reason: "user" }, + time: { started: 1, completed: 2 }, + }, output: { output: "", size: 0, cursor: 0, truncated: false }, }, }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index c265daa127e..03956deab16 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -187,7 +187,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { draft.status = event.data.shell.status draft.exit = event.data.shell.exit draft.output = event.data.output - if (event.metadata) draft.metadata = { ...draft.metadata, ...event.metadata } + if (event.data.shell.metadata.reason === "user") draft.metadata = { ...draft.metadata, reason: "user" } draft.time.completed = created }), ) diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts index 23c83e71916..79d34665f8a 100644 --- a/packages/core/src/session/session.ts +++ b/packages/core/src/session/session.ts @@ -229,11 +229,13 @@ export const make = Effect.fn("Session.make")(function* () { const preview = yield* shell .output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES }) .pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable))) - yield* bus.publish( - SessionEvent.Shell.Ended, - { sessionID, shell: terminal.info, output: preview }, - terminal.reason ? { metadata: { reason: terminal.reason } } : undefined, - ) + yield* bus.publish(SessionEvent.Shell.Ended, { + sessionID, + shell: terminal.reason + ? { ...terminal.info, metadata: { ...terminal.info.metadata, reason: terminal.reason } } + : terminal.info, + output: preview, + }) yield* synthetic(sessionID, { ...ShellResult.userNotification(terminal), resume: false, diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 157cd977b97..f78c25d40ff 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -71,7 +71,7 @@ export interface Interface { // Resolves once the command reaches a terminal status, returning its final Info. Fails with // NotFoundError if the command is unknown or is removed before it terminates. readonly wait: (id: Shell.ID) => Effect.Effect - // A known shell's terminal state and bounded tail. Missing capture remains distinct from its exit status. + // A created handle's terminal outcome survives removal; its output capture may no longer be available. readonly result: (started: Shell.Info) => Effect.Effect // Replaces the running command's timeout from now; zero clears it. readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect @@ -142,6 +142,7 @@ const layer = () => const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const commands = new Map() + const completions = new WeakMap>() const exitOrder: Shell.ID[] = [] const outputDir = path.join(global.data, DIRECTORY, location.project.id) @@ -237,7 +238,8 @@ const layer = () => }) const result = Effect.fn("Shell.result")(function* (started: Shell.Info) { - const terminal = yield* wait(started.id).pipe( + const done = completions.get(started) + const terminal = yield* (done ? Deferred.await(done) : wait(started.id)).pipe( Effect.map((info): Pick => ({ info })), Effect.catchTag("Shell.NotFoundError", (error) => Effect.succeed({ @@ -430,6 +432,8 @@ const layer = () => ) const command = yield* Deferred.await(ready) + // The original handle retains its terminal signal even if removal precedes result(). + completions.set(command.info, command.done) return command.info }) diff --git a/packages/core/test/session-shell.test.ts b/packages/core/test/session-shell.test.ts index fe7721d0cb1..bf46fab4dd9 100644 --- a/packages/core/test/session-shell.test.ts +++ b/packages/core/test/session-shell.test.ts @@ -287,6 +287,11 @@ describe("Session.shell", () => { expect(yield* fixture.session.messages({ sessionID: fixture.created.id })).toMatchObject([ { type: "shell", status: "killed", metadata: { background: true, reason: "user" } }, ]) + expect( + (yield* log(fixture.session, fixture.created.id).pipe(Stream.runCollect)).find( + (event) => event.type === "session.shell.ended", + ), + ).toMatchObject({ data: { shell: { metadata: { reason: "user" } } } }) expect(yield* fixture.session.inbox(fixture.created.id)).toMatchObject([ { type: "synthetic", @@ -300,6 +305,22 @@ describe("Session.shell", () => { }), ) + it.live("retains the stop result when the caller has not started waiting", () => + Effect.gen(function* () { + const fixture = yield* setup + const started = yield* fixture.shell.create({ + command: process.platform === "win32" ? "Start-Sleep -Seconds 60" : "sleep 60", + timeout: 0, + }) + yield* Shell.stop(started.id).pipe(Effect.provideService(Shell.Service, fixture.shell)) + expect(yield* fixture.shell.result(started)).toMatchObject({ + info: { id: started.id, status: "killed" }, + reason: "user", + capture: undefined, + }) + }), + ) + for (const outcome of [ { status: "killed", diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 59b26fa7d43..c1870d25539 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -172,7 +172,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp : members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0) ? ("question" as const) : (false as const), - busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0), + // Quiet user-stop notices are context, not pending execution. + busy: members.some( + (id) => + data.session.status(id) === "running" || + data.session.pending + .list(id) + .some( + (item) => + item.type !== "synthetic" || + item.payload.metadata?.state !== "cancelled" || + item.payload.metadata?.reason !== "user", + ), + ), renaming: data.session.title.pending(session), } } diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 3473eebf00b..fa9a4d9ae77 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -895,6 +895,47 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn } }) +test.each(["shell", "subagent"])("a quiet %s stop notice does not keep its tab busy", async (source) => { + const setup = await renderSessionTabs("parent") + + try { + await wait(() => setup.tabs.current() === "parent") + setup.emit({ + id: "evt_stopped", + created: 1, + type: "session.inbox.enqueued", + durable: { aggregateID: "parent", seq: 1, version: 1 }, + data: { + sessionID: "parent", + inboxID: "msg_stopped", + item: { + type: "synthetic", + delivery: "steer", + payload: { text: "Stopped by user", metadata: { source, state: "cancelled", reason: "user" } }, + }, + }, + }) + await wait(() => setup.data.session.pending.list("parent").length === 1) + expect(setup.tabs.status("parent").busy).toBe(false) + + for (const [index, type] of (["session.execution.started", "session.execution.succeeded"] as const).entries()) { + setup.emit({ + id: `evt_execution_${index}`, + created: 2 + index, + type, + durable: { aggregateID: "parent", seq: 2 + index, version: 1 }, + data: { sessionID: "parent" }, + }) + await wait(() => setup.tabs.status("parent").busy === (index === 0)) + } + + setup.emit(admitted("parent", "msg_4")) + await wait(() => setup.tabs.status("parent").busy) + } finally { + await setup.destroy() + } +}) + test("user prompt admissions pulse an already-busy background tab", async () => { const setup = await renderSessionTabs("background")