From 29e76b3126e5571c2cc5df19870c150b68b643d5 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 18 May 2026 09:01:22 +0800 Subject: [PATCH] fix: preserve abort interrupt provenance --- .../renderer-diagnostics-sanitize.test.ts | 31 ++++++++++++ .../src/main/renderer-diagnostics-sanitize.ts | 1 + packages/opencode/src/effect/runner.ts | 24 ++++++---- packages/opencode/src/session/run-state.ts | 20 ++++++-- packages/opencode/test/effect/runner.test.ts | 38 +++++++++++++++ .../opencode/test/session/run-state.test.ts | 47 +++++++++++++++++++ 6 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/session/run-state.test.ts diff --git a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts index 529290972..b29893b8c 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.test.ts @@ -131,6 +131,37 @@ describe("renderer diagnostics sanitizer", () => { }) }) + test("keeps session abort diagnostics and drops unrelated fields", () => { + const event = sanitizeRendererDiagnosticEvent( + { + name: "session.action.abort", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + source: "emptyEnter", + mode: "soft", + result: "aborted", + prompt_text: "do not keep me", + }, + }, + { appLaunchID: "launch_1", now: () => new Date("2026-05-02T10:30:12.123Z"), windowID: 1 }, + ) + + expect(event).toMatchObject({ + "event.name": "session.action.abort", + route_session_id: "ses_route", + visible_session_id: "ses_visible", + timeline_session_id: "ses_timeline", + data: { + source: "emptyEnter", + mode: "soft", + result: "aborted", + }, + }) + expect(JSON.stringify(event)).not.toContain("do not keep me") + }) + test("accepts typed session timeline scroll controller diagnostics", () => { const event = sanitizeRendererDiagnosticEvent( { diff --git a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts index 0a9cbc795..c69264f5c 100644 --- a/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts +++ b/packages/desktop-electron/src/main/renderer-diagnostics-sanitize.ts @@ -35,6 +35,7 @@ const eventDataFields = { "image_count", "comment_count", ], + "session.action.abort": ["source", "mode", "result"], "session.timeline.mount": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], "session.timeline.unmount": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], "session.timeline.visible": ["rendered_count", "visible_first_message_id", "visible_last_message_id"], diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index ed297c8f5..47677db2c 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -56,6 +56,7 @@ export const make = ( onIdle?: Effect.Effect onBusy?: Effect.Effect onInterrupt?: (meta?: InterruptMeta) => Effect.Effect + interruptFallback?: InterruptMeta busy?: () => never }, ): Runner => { @@ -70,6 +71,15 @@ export const make = ( ids += 1 return ids } + const withRecordedInterruptMeta = (meta: InterruptMeta | undefined, fallback: InterruptMeta): InterruptMeta => ({ + ...fallback, + ...meta, + recordedAt: meta?.recordedAt ?? Date.now(), + }) + const interruptFallback = opts?.interruptFallback ?? { + source: "runner.interrupt_without_meta", + reason: "fiber_interrupt_without_meta", + } const complete = (done: Deferred.Deferred, exit: Exit.Exit) => Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause) @@ -108,7 +118,7 @@ export const make = ( const resolveInterrupt = (interruptMeta: Ref.Ref): Effect.Effect => Effect.gen(function* () { - const meta = yield* Ref.get(interruptMeta) + const meta = withRecordedInterruptMeta(yield* Ref.get(interruptMeta), interruptFallback) if (onInterrupt) return yield* onInterrupt(meta) return yield* Effect.die(new Cancelled()) }) @@ -218,14 +228,10 @@ export const make = ( SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { - const snapshot = meta - ? { - ...meta, - recordedAt: meta.recordedAt ?? Date.now(), - } - : { - recordedAt: Date.now(), - } + const snapshot = withRecordedInterruptMeta(meta, { + source: "runner.cancel_without_meta", + reason: "cancel_without_meta", + }) switch (st._tag) { case "Idle": return [Effect.void, st] as const diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index b9a2fa06f..22a542695 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -35,10 +35,18 @@ export const layer = Layer.effect( const runners = new Map>() yield* Effect.addFinalizer( Effect.fnUntraced(function* () { - yield* Effect.forEach(runners.values(), (runner) => runner.cancel, { - concurrency: "unbounded", - discard: true, - }) + yield* Effect.forEach( + runners.values(), + (runner) => + runner.cancelWith({ + source: "session.run_state.finalizer", + reason: "scope_finalizer", + }), + { + concurrency: "unbounded", + discard: true, + }, + ) runners.clear() }), ) @@ -60,6 +68,10 @@ export const layer = Layer.effect( }), onBusy: status.set(sessionID, { type: "busy" }), onInterrupt, + interruptFallback: { + source: "session.run_state.scope", + reason: "scope_closed_without_cancel_meta", + }, busy: () => { throw new Session.BusyError(sessionID) }, diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 2f60aca36..cf34d8522 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -154,6 +154,44 @@ describe("Runner", () => { }), ) + it.live( + "cancel without metadata annotates the interrupt source", + Effect.gen(function* () { + const s = yield* Scope.Scope + const runner = Runner.make(s, { + onInterrupt: (meta) => Effect.succeed(`${meta?.source}:${meta?.reason}:${typeof meta?.recordedAt}`), + }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* runner.cancel + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) expect(exit.value).toBe("runner.cancel_without_meta:cancel_without_meta:number") + }), + ) + + it.live( + "scope interruption without metadata annotates the interrupt source", + Effect.gen(function* () { + const s = yield* Scope.make() + const runner = Runner.make(s, { + onInterrupt: (meta) => Effect.succeed(`${meta?.source}:${meta?.reason}:${typeof meta?.recordedAt}`), + }) + const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild) + yield* Effect.sleep("10 millis") + + yield* Scope.close(s, Exit.void) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value).toBe("runner.interrupt_without_meta:fiber_interrupt_without_meta:number") + } + }), + ) + it.live( "cancel with queued callers resolves all", Effect.gen(function* () { diff --git a/packages/opencode/test/session/run-state.test.ts b/packages/opencode/test/session/run-state.test.ts new file mode 100644 index 000000000..87230e1ce --- /dev/null +++ b/packages/opencode/test/session/run-state.test.ts @@ -0,0 +1,47 @@ +import { describe, expect } from "bun:test" +import { Effect, Exit, Fiber, Layer } from "effect" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { Instance } from "../../src/project/instance" +import { SessionRunState } from "../../src/session/run-state" +import { SessionID } from "../../src/session/schema" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, SessionRunState.defaultLayer)) + +describe("SessionRunState", () => { + it.live("annotates runner interrupts caused by the run-state scope closing", () => { + let captured: { source?: string; reason?: string; recordedAt?: number } | undefined + + return provideTmpdirInstance( + () => + Effect.gen(function* () { + const run = yield* SessionRunState.Service + const fiber = yield* run + .ensureRunning( + SessionID.make("ses_run_state_scope"), + (meta) => + Effect.sync(() => { + captured = meta + return {} as never + }), + Effect.never, + ) + .pipe(Effect.forkChild) + + yield* Effect.sleep("10 millis") + yield* Effect.promise(() => Instance.dispose()) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + expect(captured).toMatchObject({ + source: "session.run_state.scope", + reason: "scope_closed_without_cancel_meta", + }) + expect(typeof captured?.recordedAt).toBe("number") + }), + { git: true }, + ) + }) +})