Skip to content
Merged
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
Expand Up @@ -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(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
24 changes: 15 additions & 9 deletions packages/opencode/src/effect/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const make = <A, E = never>(
onIdle?: Effect.Effect<void>
onBusy?: Effect.Effect<void>
onInterrupt?: (meta?: InterruptMeta) => Effect.Effect<A, E>
interruptFallback?: InterruptMeta
busy?: () => never
},
): Runner<A, E> => {
Expand All @@ -70,6 +71,15 @@ export const make = <A, E = never>(
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<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
Expand Down Expand Up @@ -108,7 +118,7 @@ export const make = <A, E = never>(

const resolveInterrupt = (interruptMeta: Ref.Ref<InterruptMeta | undefined>): Effect.Effect<A, E> =>
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())
})
Expand Down Expand Up @@ -218,14 +228,10 @@ export const make = <A, E = never>(
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
Expand Down
20 changes: 16 additions & 4 deletions packages/opencode/src/session/run-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,18 @@ export const layer = Layer.effect(
const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
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()
}),
)
Expand All @@ -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)
},
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/test/effect/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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<string>(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* () {
Expand Down
47 changes: 47 additions & 0 deletions packages/opencode/test/session/run-state.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
})
})
Loading