Skip to content

Commit 75d141b

Browse files
authored
fix(session): cancel subtask child sessions (#25798)
1 parent 39c88f9 commit 75d141b

4 files changed

Lines changed: 317 additions & 225 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import path from "path"
22
import os from "os"
3-
import z from "zod"
43
import * as EffectZod from "@/util/effect-zod"
54
import { SessionID, MessageID, PartID } from "./schema"
65
import { MessageV2 } from "./message-v2"
@@ -121,9 +120,8 @@ export const layer = Layer.effect(
121120
return yield* EffectBridge.make()
122121
})
123122
const ops = Effect.fn("SessionPrompt.ops")(function* () {
124-
const run = yield* runner()
125123
return {
126-
cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)),
124+
cancel: (sessionID: SessionID) => cancel(sessionID),
127125
resolvePromptParts: (template: string) => resolvePromptParts(template),
128126
prompt: (input: PromptInput) => prompt(input),
129127
} satisfies TaskPromptOps

packages/opencode/src/tool/task.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { MessageV2 } from "../session/message-v2"
66
import { Agent } from "../agent/agent"
77
import type { SessionPrompt } from "../session/prompt"
88
import { Config } from "@/config/config"
9-
import { Effect, Schema } from "effect"
9+
import { Effect, Exit, Schema } from "effect"
10+
import { EffectBridge } from "@/effect/bridge"
1011

1112
export interface TaskPromptOps {
12-
cancel(sessionID: SessionID): void
13+
cancel(sessionID: SessionID): Effect.Effect<void>
1314
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
1415
prompt(input: SessionPrompt.PromptInput): Effect.Effect<MessageV2.WithParts>
1516
}
@@ -118,16 +119,18 @@ export const TaskTool = Tool.define(
118119

119120
const ops = ctx.extra?.promptOps as TaskPromptOps
120121
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
122+
const runCancel = yield* EffectBridge.make()
121123

122124
const messageID = MessageID.ascending()
125+
const cancel = ops.cancel(nextSession.id)
123126

124-
function cancel() {
125-
ops.cancel(nextSession.id)
127+
function onAbort() {
128+
runCancel.fork(cancel)
126129
}
127130

128131
return yield* Effect.acquireUseRelease(
129132
Effect.sync(() => {
130-
ctx.abort.addEventListener("abort", cancel)
133+
ctx.abort.addEventListener("abort", onAbort)
131134
}),
132135
() =>
133136
Effect.gen(function* () {
@@ -163,10 +166,16 @@ export const TaskTool = Tool.define(
163166
].join("\n"),
164167
}
165168
}),
166-
() =>
167-
Effect.sync(() => {
168-
ctx.abort.removeEventListener("abort", cancel)
169-
}),
169+
(_, exit) =>
170+
Effect.gen(function* () {
171+
if (Exit.hasInterrupts(exit)) yield* cancel
172+
}).pipe(
173+
Effect.ensuring(
174+
Effect.sync(() => {
175+
ctx.abort.removeEventListener("abort", onAbort)
176+
}),
177+
),
178+
),
170179
)
171180
})
172181

packages/opencode/test/session/prompt.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -858,6 +858,43 @@ it.live(
858858
30_000,
859859
)
860860

861+
it.live(
862+
"cancel propagates from slash command subtask to child session",
863+
() =>
864+
provideTmpdirServer(
865+
Effect.fnUntraced(function* ({ llm }) {
866+
const prompt = yield* SessionPrompt.Service
867+
const sessions = yield* Session.Service
868+
const status = yield* SessionStatus.Service
869+
const chat = yield* sessions.create({ title: "Pinned" })
870+
yield* llm.hang
871+
const msg = yield* user(chat.id, "hello")
872+
yield* addSubtask(chat.id, msg.id)
873+
874+
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild)
875+
yield* llm.wait(1)
876+
877+
const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
878+
const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general")
879+
const tool = taskMsg ? toolPart(taskMsg.parts) : undefined
880+
const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined
881+
expect(typeof sessionID).toBe("string")
882+
if (typeof sessionID !== "string") throw new Error("missing child session id")
883+
const childID = SessionID.make(sessionID)
884+
expect((yield* status.get(childID)).type).toBe("busy")
885+
886+
yield* prompt.cancel(chat.id)
887+
const exit = yield* Fiber.await(fiber)
888+
expect(Exit.isSuccess(exit)).toBe(true)
889+
890+
expect((yield* status.get(chat.id)).type).toBe("idle")
891+
expect((yield* status.get(childID)).type).toBe("idle")
892+
}),
893+
{ git: true, config: providerCfg },
894+
),
895+
10_000,
896+
)
897+
861898
it.live(
862899
"cancel with queued callers resolves all cleanly",
863900
() =>

0 commit comments

Comments
 (0)