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
8 changes: 6 additions & 2 deletions packages/opencode/src/effect/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface PendingHandle<A, E> {
export interface InterruptMeta {
source?: string
reason?: string
lifecycleActionID?: string
lifecycleKind?: string
// Reserved for future paths that originate from a tool or model ctx.abort signal instead of
// an explicit session.cancel call.
viaCtxAbort?: boolean
Expand All @@ -55,7 +57,7 @@ export const make = <A, E = never>(
onIdle?: Effect.Effect<void>
onBusy?: Effect.Effect<void>
onInterrupt?: (meta?: InterruptMeta) => Effect.Effect<A, E>
interruptFallback?: InterruptMeta
interruptFallback?: InterruptMeta | (() => InterruptMeta)
busy?: () => never
},
): Runner<A, E> => {
Expand All @@ -79,6 +81,8 @@ export const make = <A, E = never>(
source: "runner.interrupt_without_meta",
reason: "fiber_interrupt_without_meta",
}
const getInterruptFallback = () =>
typeof interruptFallback === "function" ? interruptFallback() : interruptFallback

const complete = (done: Deferred.Deferred<A, E | Cancelled>, exit: Exit.Exit<A, E>) =>
Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)
Expand Down Expand Up @@ -117,7 +121,7 @@ export const make = <A, E = never>(

const resolveInterrupt = (interruptMeta: Ref.Ref<InterruptMeta | undefined>): Effect.Effect<A, E> =>
Effect.gen(function* () {
const meta = withRecordedInterruptMeta(yield* Ref.get(interruptMeta), interruptFallback)
const meta = withRecordedInterruptMeta(yield* Ref.get(interruptMeta), getInterruptFallback())
if (onInterrupt) return yield* onInterrupt(meta)
return yield* Effect.die(new Cancelled())
})
Expand Down
29 changes: 20 additions & 9 deletions packages/opencode/src/project/instance-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { InstanceBootstrap } from "./bootstrap-service"
import { type InstanceContext } from "./instance-context"
import { Project } from "./project"
import { State } from "./state"
import {
createLifecycleCloseAction,
type LifecycleCloseAction,
withLifecycleCloseAction,
} from "@/session/lifecycle-provenance"

export interface LoadInput {
directory: string
Expand Down Expand Up @@ -111,19 +116,22 @@ export const layer = Layer.effect(
}),
)

const disposeContext = (ctx: InstanceContext) =>
const disposeContext = (ctx: InstanceContext, action?: LifecycleCloseAction) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await State.dispose(ctx.directory)
await runDisposers(ctx.directory)
const closeAction = action ?? createLifecycleCloseAction("instance_dispose")
await withLifecycleCloseAction([ctx.directory], closeAction, async () => {
await State.dispose(ctx.directory)
await runDisposers(ctx.directory)
})
})
yield* emitDisposed(ctx)
})

const disposeEntry = (directory: string, entry: Entry, ctx: InstanceContext) =>
const disposeEntry = (directory: string, entry: Entry, ctx: InstanceContext, action?: LifecycleCloseAction) =>
Effect.gen(function* () {
if (entries.get(directory) !== entry) return false
yield* disposeContext(ctx)
yield* disposeContext(ctx, action)
if (entries.get(directory) !== entry) return false
entries.delete(directory)
return true
Expand All @@ -140,7 +148,7 @@ export const layer = Layer.effect(
yield* Effect.gen(function* () {
if (previous) {
const exit = yield* Deferred.await(previous.deferred).pipe(Effect.exit)
if (Exit.isSuccess(exit)) yield* disposeContext(exit.value)
if (Exit.isSuccess(exit)) yield* disposeContext(exit.value, createLifecycleCloseAction("instance_reload"))
else yield* removeEntry(directory, previous)
}
yield* completeLoad(directory, input, entry)
Expand Down Expand Up @@ -184,7 +192,7 @@ export const layer = Layer.effect(
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) return yield* removeEntry(directory, entry).pipe(Effect.asVoid)
if (exit.value !== ctx) return
yield* disposeEntry(directory, entry, ctx).pipe(Effect.asVoid)
yield* disposeEntry(directory, entry, ctx, createLifecycleCloseAction("instance_dispose")).pipe(Effect.asVoid)
})

const disposeDirectory = (inputDirectory: string) =>
Expand All @@ -195,10 +203,13 @@ export const layer = Layer.effect(

const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) return yield* removeEntry(directory, entry).pipe(Effect.asVoid)
yield* disposeEntry(directory, entry, exit.value).pipe(Effect.asVoid)
yield* disposeEntry(directory, entry, exit.value, createLifecycleCloseAction("instance_dispose_directory")).pipe(
Effect.asVoid,
)
})

const disposeAllOnce = Effect.gen(function* () {
const action = createLifecycleCloseAction("instance_dispose_all")
yield* Effect.forEach(
[...entries.entries()],
([directory, entry]) =>
Expand All @@ -208,7 +219,7 @@ export const layer = Layer.effect(
yield* removeEntry(directory, entry)
return
}
yield* disposeEntry(directory, entry, exit.value)
yield* disposeEntry(directory, entry, exit.value, action)
}),
{ discard: true },
)
Expand Down
45 changes: 45 additions & 0 deletions packages/opencode/src/session/lifecycle-provenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { LifecycleKind } from "./run-observability/types"

export type LifecycleCloseAction = {
actionID: string
kind: LifecycleKind
}

let nextActionID = 0
const activeByDirectory = new Map<string, LifecycleCloseAction[]>()

export function createLifecycleCloseAction(kind: LifecycleKind): LifecycleCloseAction {
nextActionID += 1
return {
actionID: `lifecycle:${kind}:${Date.now().toString(36)}:${nextActionID.toString(36)}`,
kind,
}
}

export async function withLifecycleCloseAction<T>(
directories: string[],
action: LifecycleCloseAction,
fn: () => Promise<T>,
): Promise<T> {
for (const directory of directories) {
const stack = activeByDirectory.get(directory) ?? []
stack.push(action)
activeByDirectory.set(directory, stack)
}
try {
return await fn()
} finally {
for (const directory of directories) {
const stack = activeByDirectory.get(directory)
if (!stack) continue
const index = stack.lastIndexOf(action)
if (index >= 0) stack.splice(index, 1)
if (stack.length) activeByDirectory.set(directory, stack)
else activeByDirectory.delete(directory)
}
}
}
Comment thread
Astro-Han marked this conversation as resolved.

export function currentLifecycleCloseAction(directory: string): LifecycleCloseAction | undefined {
return activeByDirectory.get(directory)?.at(-1)
}
8 changes: 8 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import { ExternalResult } from "@/tool/external-result"
import { errorMessage } from "@/util/error"
import { Log } from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { InstanceState } from "@/effect/instance-state"
import { TurnChange } from "./turn-change"
import { LLMTrace } from "./llm-trace"
import { RunObservability } from "./run-observability"
import { currentLifecycleCloseAction } from "./lifecycle-provenance"

const log = Log.create({ service: "session.processor" })
const TOOL_CLEANUP_TIMEOUT_MS = 1_000
Expand Down Expand Up @@ -120,6 +122,7 @@ type PendingLoopAction = {
}

interface ProcessorContext extends Input {
directory: string
toolcalls: Record<string, ToolCall>
pendingLoopActions: Record<string, PendingLoopAction>
pendingToolUpdates: Record<string, Array<(part: MessageV2.ToolPart) => MessageV2.ToolPart>>
Expand Down Expand Up @@ -175,10 +178,12 @@ export const layer: Layer.Layer<
// may execute tools internally before emitting start-step events,
// so capturing inside the event handler can be too late.
const initialSnapshot = yield* snapshot.track()
const instanceContext = yield* InstanceState.context
const ctx: ProcessorContext = {
assistantMessage: input.assistantMessage,
sessionID: input.sessionID,
model: input.model,
directory: instanceContext.directory,
toolcalls: {},
pendingLoopActions: {},
pendingToolUpdates: {},
Expand Down Expand Up @@ -1073,12 +1078,15 @@ export const layer: Layer.Layer<
Effect.onInterrupt(() =>
Effect.gen(function* () {
aborted = true
const lifecycleAction = currentLifecycleCloseAction(ctx.directory)
ctx.runTrace.recordScopeClosed({
at: Date.now(),
monotonicMs: performance.now(),
source: "session.processor.onInterrupt",
reason: "aborted",
propagationPoint: "session.processor.process.onInterrupt",
lifecycleActionID: lifecycleAction?.actionID,
lifecycleKind: lifecycleAction?.kind,
})
ctx.trace.recordAbortState({
provenanceSource: "session.processor.onInterrupt",
Expand Down
29 changes: 27 additions & 2 deletions packages/opencode/src/session/run-observability/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SCHEMA_VERSION,
type Summary,
type SummaryKey,
type LifecycleKind,
type ToolEffectKind,
} from "./types"
import { safeErrorFingerprint } from "./sanitize"
Expand All @@ -25,6 +26,7 @@ type Failure =
source?: string
reason?: string
lifecycleActionID?: string
lifecycleKind?: LifecycleKind
}
| { type: "tool"; at: number; monotonicMs: number; error?: unknown; attemptID?: AttemptID }

Expand Down Expand Up @@ -155,6 +157,7 @@ export function createRecorder(input: RecorderInput): Recorder {
source: next.source,
reason: next.reason,
lifecycleActionID: next.lifecycleActionID,
lifecycleKind: next.lifecycleKind,
}
rememberEvent(next.monotonicMs)
},
Expand Down Expand Up @@ -196,6 +199,7 @@ export function createRecorder(input: RecorderInput): Recorder {
unsafe_side_effect_started: unsafeSideEffectStarted,
unsafe_side_effect_kinds: unsafeKinds,
side_effect_facts_complete: sideEffectFactsComplete,
lifecycle: lifecycleSummary(failure),
missing_provenance: missingProvenance,
durations_ms: {
total: duration(input.monotonicStartMs, final.monotonicMs),
Expand All @@ -219,8 +223,16 @@ function classify(failure: Failure | undefined): Classification {
if (!failure) return "success"
if (failure.type === "setup") return "request_setup_failure"
if (failure.type === "tool") return "tool_failure"
if (failure.type === "scope_closed")
if (failure.type === "scope_closed") {
if (failure.lifecycleKind === "instance_reload") return "local_instance_reload"
if (
failure.lifecycleKind === "instance_dispose" ||
failure.lifecycleKind === "instance_dispose_directory" ||
failure.lifecycleKind === "instance_dispose_all"
)
return "local_instance_dispose"
return failure.lifecycleActionID ? "known_lifecycle_close" : "unknown_scope_close"
}
if (failure.type === "transport") return "external_stream_disconnect"
return "unknown_failure"
}
Expand All @@ -232,7 +244,10 @@ function summarySuffix(input: { failure: Failure | undefined; providerProgressSe
if (input.providerProgressSeen) return "provider_progress_transport_failure"
return "transport_failure"
}
if (input.failure?.type === "scope_closed") return "missing_lifecycle_provenance"
if (input.failure?.type === "scope_closed") {
if (input.failure.lifecycleActionID) return "lifecycle_close"
return "missing_lifecycle_provenance"
}
if (input.failure?.type === "setup") return "request_setup_failed"
if (input.failure?.type === "tool") return "tool_execution_failed"
if (!input.failure) return "completed"
Expand All @@ -243,6 +258,16 @@ export function summaryKeyFor(classification: Classification, suffix: string): S
return `${classification}.${suffix}` as SummaryKey
}

function lifecycleSummary(failure: Failure | undefined): Summary["lifecycle"] {
if (failure?.type !== "scope_closed" || !failure.lifecycleActionID || !failure.lifecycleKind) return undefined
return {
action_id: failure.lifecycleActionID,
kind: failure.lifecycleKind,
source: failure.source,
reason: failure.reason,
}
}

export function isProviderProgressEvent(event: { type: string }) {
switch (event.type) {
case "text-start":
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/session/run-observability/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type AttemptID = z.infer<typeof AttemptID>
export const Classification = z.enum([
"success",
"external_stream_disconnect",
"local_instance_reload",
"local_instance_dispose",
"known_lifecycle_close",
"unknown_scope_close",
"request_setup_failure",
Expand Down Expand Up @@ -48,6 +50,8 @@ export type SafeErrorFingerprint = {
cause_code?: string
}

export type LifecycleKind = "instance_reload" | "instance_dispose" | "instance_dispose_directory" | "instance_dispose_all"

export type ToolEffectKind = "read_only" | "local_file_write" | "local_process" | "unknown"
export type ToolEffect = {
kind: ToolEffectKind
Expand Down Expand Up @@ -91,6 +95,12 @@ export type Summary = {
unsafe_side_effect_started: boolean
unsafe_side_effect_kinds: ToolEffectKind[]
side_effect_facts_complete: boolean
lifecycle?: {
action_id: string
kind: LifecycleKind
source?: string
reason?: string
}
missing_provenance?: string[]
durations_ms: {
total?: number
Expand Down Expand Up @@ -147,6 +157,7 @@ export type Recorder = {
reason?: string
propagationPoint?: string
lifecycleActionID?: string
lifecycleKind?: LifecycleKind
}): void
finalize(input: { completedAt?: number; monotonicMs: number }): Summary
}
Expand Down
23 changes: 17 additions & 6 deletions packages/opencode/src/session/run-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionID } from "./schema"
import { SessionStatus } from "./status"
import { currentLifecycleCloseAction } from "./lifecycle-provenance"

export interface Interface {
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
Expand All @@ -30,17 +31,30 @@ export const layer = Layer.effect(
const status = yield* SessionStatus.Service

const state = yield* InstanceState.make(
Effect.fn("SessionRunState.state")(function* () {
Effect.fn("SessionRunState.state")(function* (ctx) {
const scope = yield* Scope.Scope
const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
let scopeCloseAction = currentLifecycleCloseAction(ctx.directory)
const lifecycleAction = () => currentLifecycleCloseAction(ctx.directory)
const interruptFallback = () => {
const action = lifecycleAction() ?? scopeCloseAction
return {
source: "session.run_state.scope",
reason: "scope_closed_without_cancel_meta",
...(action ? { lifecycleActionID: action.actionID, lifecycleKind: action.kind } : {}),
} satisfies InterruptMeta
}
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
const action = lifecycleAction()
scopeCloseAction = action ?? scopeCloseAction
yield* Effect.forEach(
runners.values(),
(runner) =>
runner.cancelWith({
source: "session.run_state.finalizer",
reason: "scope_finalizer",
...(action ? { lifecycleActionID: action.actionID, lifecycleKind: action.kind } : {}),
}),
{
concurrency: "unbounded",
Expand All @@ -50,7 +64,7 @@ export const layer = Layer.effect(
runners.clear()
}),
)
return { runners, scope }
return { runners, scope, interruptFallback }
}),
)

Expand All @@ -68,10 +82,7 @@ export const layer = Layer.effect(
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt,
interruptFallback: {
source: "session.run_state.scope",
reason: "scope_closed_without_cancel_meta",
},
interruptFallback: data.interruptFallback,
busy: () => {
throw new Session.BusyError(sessionID)
},
Expand Down
Loading