fix(core): preserve stopped shell outcomes through replay

This commit is contained in:
Kit Langton 2026-08-31 13:45:26 -04:00
parent 0a0cf941f0
commit fc1c91d392
8 changed files with 96 additions and 12 deletions

View file

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

View file

@ -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 },
},
})

View file

@ -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
}),
)

View file

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

View file

@ -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<Shell.Info, NotFoundError>
// 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<ShellResult.Result>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
@ -142,6 +142,7 @@ const layer = () =>
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const commands = new Map<Shell.ID, Active>()
const completions = new WeakMap<Info, Deferred.Deferred<Info, NotFoundError>>()
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<ShellResult.Result, "info" | "reason"> => ({ 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
})

View file

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

View file

@ -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),
}
}

View file

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