fix(core): isolate shell jobs by shell ID (#45985)

Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-08-28 13:13:16 -05:00 committed by GitHub
parent 732f949a65
commit f6992059be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 67 additions and 14 deletions

View file

@ -80,8 +80,8 @@ export function createSessionRequestModel() {
if (message.type !== "synthetic") return []
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
return [message.metadata.childID]
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
return [message.metadata.jobID]
if (message.metadata?.source === "shell")
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
return []
}),
)
@ -121,6 +121,7 @@ export function createSessionRequestModel() {
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
const shellID = part.state.metadata.shellID
if (typeof shellID === "string" && completed.has(shellID)) return []
const command = part.state.input.command
return [
{

View file

@ -280,7 +280,8 @@ export const Plugin = {
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.id,
// CodeMode children share a tool-call ID, but each shell must own its job.
id: info.id,
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
@ -295,7 +296,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
}
@ -304,7 +305,7 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
return backgroundResult(info.id, info.file)
}
if (result?.info.status === "error")

View file

@ -379,7 +379,7 @@ describe("SessionRestart background recovery", () => {
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
yield* seedBackground(jobs, parent, [
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
{ id: "sh_background_orphan", shellID: "sh_background_orphan", command: "sleep 60" },
])
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
@ -413,7 +413,7 @@ describe("SessionRestart background recovery", () => {
text: expect.stringContaining("server restarted"),
metadata: {
source: "shell",
jobID: "call-background-shell",
jobID: "sh_background_orphan",
shellID: "sh_background_orphan",
state: "cancelled",
},

View file

@ -702,6 +702,42 @@ describe("ShellTool ordinary shell syntax", () => {
})
describe("ShellTool", () => {
it.live("returns both parallel CodeMode shell results", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
yield* registry.transform((draft) =>
draft.update("shell", (tool) => {
tool.options = { ...tool.options, codemode: true }
}),
)
const command = isWindows ? helloCommand : `${helloCommand}; sleep 0.1`
const inputs = ["one", "two"].map((text) => JSON.stringify({ command: command.replace("hello", text) }))
const result = yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: {
type: "tool-call",
id: "call-parallel-shells",
name: "execute",
input: { code: `return await Promise.all([tools.shell(${inputs[0]}), tools.shell(${inputs[1]})])` },
},
}).pipe(Effect.timeout("3 seconds"))
expect(result.status).toBe("completed")
expect(JSON.parse(result.output.output)).toEqual([
{ output: "one", exit: 0, truncated: false, status: "completed" },
{ output: "two", exit: 0, truncated: false, status: "completed" },
])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
productionIt.live(
"registers and returns real successful output from the active Location",
() =>
@ -1342,7 +1378,7 @@ describe("ShellTool", () => {
description: bodyExitCommand,
metadata: {
source: "shell",
jobID: "call-background-nonzero",
jobID: shellID,
shellID,
state: "completed",
exit: 7,
@ -1375,7 +1411,7 @@ describe("ShellTool", () => {
)
: Effect.void,
)
yield* executeTool(registry, {
const settled = yield* executeTool(registry, {
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
// The command can finish while its initial progress update is being published.
progress: (update) =>
@ -1386,7 +1422,7 @@ describe("ShellTool", () => {
expect(yield* Deferred.await(persisted)).toMatchObject([
{
id: "call-background-silent-nonzero",
id: settled.metadata?.shellID,
status: "completed",
output: "(no output)\n\nCommand exited with code 7.",
},
@ -1519,9 +1555,10 @@ describe("ShellTool", () => {
yield* Effect.promise(() => Bun.sleep(1))
return yield* backgroundWhenReady(remaining - 1)
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const backgrounded = yield* backgroundWhenReady()
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(backgrounded).toMatchObject([{ id: shellID, type: "shell" }])
expect(settled.metadata).toMatchObject({ truncated: false })
expect(shellID).toStartWith("sh_")

View file

@ -2039,7 +2039,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const source = () => stringValue(metadata()?.source)
const target = createMemo<BackgroundToolTarget | undefined>(() => {
if (source() === "shell") {
const id = stringValue(metadata()?.jobID)
const id = stringValue(metadata()?.shellID) ?? stringValue(metadata()?.jobID)
return id ? { source: "shell", id } : undefined
}
if (source() === "subagent") {

View file

@ -410,10 +410,17 @@ export function backgroundToolRowIndex(
const end = rows.findIndex((row) => row.type === "message" && row.messageID === beforeMessageID)
return rows.slice(0, end === -1 ? rows.length : end).findLastIndex((row) => {
if (row.type !== "part") return false
if (target.source === "shell") return row.ref.partID === target.id
if (target.source === "shell" && row.ref.partID === target.id) return true
const message = byID.get(row.ref.messageID)
if (message?.type !== "assistant") return false
const part = resolvePart(message, row.ref.partID)
if (target.source === "shell")
return (
part?.type === "tool" &&
part.name.toLowerCase() === "shell" &&
part.state.status !== "streaming" &&
part.state.metadata?.shellID === target.id
)
return (
part?.type === "tool" &&
part.name.toLowerCase() === "subagent" &&

View file

@ -169,7 +169,13 @@ test("assigns stable IDs to tool rows for direct navigation", () => {
test("finds background tool launch rows for completion navigation", () => {
const messages: SessionMessageInfo[] = [
assistant("assistant-1", [
{ type: "tool", id: "shell-1", name: "shell", state: pending(), time: { created: 1 } },
{
type: "tool",
id: "shell-1",
name: "shell",
state: completed({ shellID: "sh_first", status: "running" }),
time: { created: 1 },
},
]),
assistant("assistant-2", [
{
@ -214,6 +220,7 @@ test("finds background tool launch rows for completion navigation", () => {
const rows = reduceSessionRows(messages)
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "shell-1" }, "completion-2")).toBe(0)
expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "sh_first" }, "completion-2")).toBe(0)
expect(backgroundToolRowIndex(rows, messages, { source: "subagent", id: "child-1" }, "completion-1")).toBe(1)
expect(backgroundToolRowIndex(rows, messages, { source: "subagent", id: "child-1" }, "completion-2")).toBe(3)
})