Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Benjamin Shafii <ben@prologe.io>
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<void>()
+ 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

26 changes: 26 additions & 0 deletions openwork/patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading