From b483d06ba5a48773699f184d405683a828fcdd55 Mon Sep 17 00:00:00 2001 From: lex Date: Sun, 9 Aug 2026 15:29:39 +0800 Subject: [PATCH] fix(dag): cancel orphan child session --- packages/opencode/src/dag/runtime/spawn.ts | 1 + .../test/dag/spawn-completion.test.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/opencode/src/dag/runtime/spawn.ts b/packages/opencode/src/dag/runtime/spawn.ts index 008ef15ad2..aa66f05966 100644 --- a/packages/opencode/src/dag/runtime/spawn.ts +++ b/packages/opencode/src/dag/runtime/spawn.ts @@ -393,6 +393,7 @@ export function spawnNode( return true }), ), + Effect.onError(() => promptSvc.cancel(childSession.id).pipe(Effect.ignore)), ) if (terminalized) return diff --git a/packages/opencode/test/dag/spawn-completion.test.ts b/packages/opencode/test/dag/spawn-completion.test.ts index b38c2269de..585bc8c45f 100644 --- a/packages/opencode/test/dag/spawn-completion.test.ts +++ b/packages/opencode/test/dag/spawn-completion.test.ts @@ -316,4 +316,42 @@ describe("spawnNode terminalization during spawn window", () => { expect(events.filter((e) => e.type === "nodeCompleted")).toEqual([]) expect(cancelCalled).toBe(true) }) + + it("cancels the child session when nodeStarted fails after session creation", async () => { + const events: TrackedEvent[] = [] + let cancelCalled = false + let promptCalled = false + const dagLayer = Layer.mock(Dag.Service, { + store: {} as DagStore.Interface, + nodeQueued: () => Effect.void, + nodeStarted: () => Effect.fail(new Error("nodeStarted write failed")), + nodeCompleted: Effect.fn("stub.nodeCompleted")((dagID: string, nodeID: string) => + Effect.sync(() => events.push({ type: "nodeCompleted", dagID, nodeID })), + ), + nodeFailed: Effect.fn("stub.nodeFailed")((dagID: string, nodeID: string, reason: string) => + Effect.sync(() => events.push({ type: "nodeFailed", dagID, nodeID, reason })), + ), + }) + const promptLayer = Layer.mock(SessionPrompt.Service, { + prompt: () => + Effect.sync(() => { + promptCalled = true + return reply("unexpected") + }), + cancel: () => Effect.sync(() => { cancelCalled = true }), + }) + + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const result = yield* spawnNode(Semaphore.makeUnsafe(1), makeSpawnInput()) + yield* Fiber.await(result.fiber) + }), + ).pipe(Effect.provide(Layer.mergeAll(dagLayer, agentLayer, sessionLayer, promptLayer))) as Effect.Effect, + ) + + expect(promptCalled).toBe(false) + expect(findEvent(events, "nodeFailed")?.reason).toContain("nodeStarted write failed") + expect(cancelCalled).toBe(true) + }) })