From f2c2cf513c67287c03781dbee6bf98f99aaca345 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 10 Sep 2026 08:19:14 -0700 Subject: [PATCH] fix(opencode): ship parent-first cancellation patch --- ...errupt-parent-before-cancelling-task.patch | 107 ++++++++++++++++++ openwork/patches/README.md | 26 +++++ 2 files changed, 133 insertions(+) create mode 100644 openwork/patches/0001-fix-opencode-interrupt-parent-before-cancelling-task.patch diff --git a/openwork/patches/0001-fix-opencode-interrupt-parent-before-cancelling-task.patch b/openwork/patches/0001-fix-opencode-interrupt-parent-before-cancelling-task.patch new file mode 100644 index 000000000000..2874975cf3ba --- /dev/null +++ b/openwork/patches/0001-fix-opencode-interrupt-parent-before-cancelling-task.patch @@ -0,0 +1,107 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Benjamin Shafii +Date: Thu, 10 Sep 2026 08:10:32 -0700 +Subject: [PATCH] fix(opencode): interrupt parent before cancelling task jobs + +--- + packages/opencode/src/session/run-state.ts | 8 +-- + packages/opencode/test/tool/task.test.ts | 61 ++++++++++++++++++++++ + 2 files changed, 66 insertions(+), 3 deletions(-) + +diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts +index 5cefdd0..1a51371 100644 +--- a/packages/opencode/src/session/run-state.ts ++++ b/packages/opencode/src/session/run-state.ts +@@ -75,14 +75,16 @@ const layer = Layer.effect( + }) + + const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { +- yield* cancelBackgroundJobs(background, sessionID) + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) ++ // Settling a foreground task job wakes its waiting parent. Interrupt the ++ // parent first so that wakeup cannot start another provider turn. ++ if (existing) yield* existing.cancel ++ // Jobs can exist before a runner is registered, or outlive its scope. ++ yield* cancelBackgroundJobs(background, sessionID) + if (!existing) { + yield* status.set(sessionID, { type: "idle" }) +- return + } +- yield* existing.cancel + }) + + const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( +diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts +index 2bcf05a..99648b6 100644 +--- a/packages/opencode/test/tool/task.test.ts ++++ b/packages/opencode/test/tool/task.test.ts +@@ -982,4 +982,65 @@ describe("tool.task", () => { + expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled") + }), + ) ++ ++ it.instance("interrupts the parent before settling child jobs, without continuing the parent", () => ++ Effect.gen(function* () { ++ const jobs = yield* BackgroundJob.Service ++ const runState = yield* SessionRunState.Service ++ const sessions = yield* Session.Service ++ const { chat, assistant } = yield* seed() ++ const child = yield* sessions.create({ parentID: chat.id, title: "child" }) ++ const grandchild = yield* sessions.create({ parentID: child.id, title: "grandchild" }) ++ const other = yield* sessions.create({ title: "unrelated" }) ++ const ready = yield* Deferred.make() ++ const events: string[] = [] ++ for (const item of [ ++ { session: child, parentID: chat.id }, ++ { session: grandchild, parentID: child.id }, ++ { session: other, parentID: other.id }, ++ ]) { ++ yield* jobs.start({ ++ id: item.session.id, ++ type: "task", ++ metadata: { parentSessionId: item.parentID, sessionId: item.session.id }, ++ run: Effect.never.pipe( ++ Effect.onInterrupt(() => ++ Effect.sync(() => { ++ events.push(item.session.id) ++ }), ++ ), ++ ), ++ }) ++ } ++ const result = { info: assistant, parts: [] } ++ const parent = yield* runState ++ .ensureRunning( ++ chat.id, ++ Effect.succeed(result), ++ Effect.gen(function* () { ++ yield* Deferred.succeed(ready, undefined) ++ yield* jobs.wait({ id: child.id }) ++ events.push("continued") ++ return result ++ }).pipe( ++ Effect.onInterrupt(() => ++ Effect.sync(() => { ++ events.push("parent interrupted") ++ }), ++ ), ++ ), ++ ) ++ .pipe(Effect.forkChild) ++ yield* Deferred.await(ready) ++ yield* runState.cancel(chat.id) ++ yield* Fiber.join(parent) ++ expect(events[0]).toBe("parent interrupted") ++ expect(events).not.toContain("continued") ++ expect(events).toContain(child.id) ++ expect(events).toContain(grandchild.id) ++ expect((yield* jobs.get(child.id))?.status).toBe("cancelled") ++ expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled") ++ expect((yield* jobs.get(other.id))?.status).toBe("running") ++ }), ++ ) + }) +-- +2.43.0 + diff --git a/openwork/patches/README.md b/openwork/patches/README.md index 3acf62e482d1..aaa25b217f59 100644 --- a/openwork/patches/README.md +++ b/openwork/patches/README.md @@ -21,6 +21,32 @@ Patches applied on top of the upstream `anomalyco/opencode` tag when Patches are applied with `git am` in filename order. Keep them small, upstream them when possible, and delete them once upstream ships the fix. +## Parent-first cancellation + +`0001-fix-opencode-interrupt-parent-before-cancelling-task.patch` targets +upstream `v1.18.18`. It interrupts a parent runner before cancelling its task +jobs, preventing a waiting foreground task from waking an uncancelled parent +and issuing another provider request during Stop. The job sweep still runs +when no parent runner exists and still includes recursive descendants. + +The patch includes a regression in `test/tool/task.test.ts` using the real +runner and background-job services, a readiness barrier, recursive children, +and an unrelated job. The test fails against unpatched `v1.18.18` and passes +with the patch. Existing no-runner cancellation tests remain enabled. + +Local verification from `packages/opencode`, using Bun 1.3.14: + +```sh +bun test test/tool/task.test.ts test/background/job.test.ts test/effect/runner.test.ts +bun typecheck +OPENCODE_VERSION=1.18.18-openwork.parent-first.84d1dc1 bun run script/build.ts --single --skip-install --skip-embed-web-ui +``` + +The local binary is a verification candidate, not a published release. Release +dispatch and the consuming OpenWork pin update must wait for PR approval and +the native Stop journey results. Keep `upstream_ref` at `v1.18.18` when building +this patch; a different upstream base requires revalidation. + ## Cutting a release manually Actions → engine-release → run with `upstream_ref` (e.g. `v1.16.2`) and