mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 06:44:26 +00:00
fix(core): preserve stopped shell outcomes through replay
This commit is contained in:
parent
0a0cf941f0
commit
fc1c91d392
8 changed files with 96 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue