mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
162 lines
6.9 KiB
TypeScript
162 lines
6.9 KiB
TypeScript
export * as SessionExecution from "./execution.js"
|
|
|
|
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
|
|
import { Bus } from "../bus.js"
|
|
import { LocationServiceMap } from "../location-service-map.js"
|
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
|
import { SessionEvent } from "./event.js"
|
|
import { SessionRunCoordinator } from "./run-coordinator.js"
|
|
import { SessionRunner } from "./runner/index.js"
|
|
import { SessionSchema } from "./schema.js"
|
|
import { SessionStore } from "./store.js"
|
|
import { toSessionError } from "./to-session-error.js"
|
|
import { UserInterruptedError } from "./error.js"
|
|
|
|
export interface Interface {
|
|
/** Snapshots active execution owned by this process. */
|
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
|
/** Starts execution while idle or joins the active execution. */
|
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
|
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
|
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
|
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
|
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
|
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
|
}
|
|
|
|
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
|
|
|
|
type InterruptReason = "user" | "shutdown" | "superseded"
|
|
|
|
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
|
|
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
|
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
|
|
const failure = Cause.squash(exit.cause)
|
|
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
|
|
return { type: "failed" as const, error: toSessionError(failure) }
|
|
}
|
|
|
|
/** Process-local execution: drains run in this process, routed through the Session's Location graph. */
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const store = yield* SessionStore.Service
|
|
const locations = yield* LocationServiceMap.Service
|
|
const bus = yield* Bus.Service
|
|
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
|
effect.pipe(
|
|
Effect.tapCause((cause) =>
|
|
Cause.hasInterruptsOnly(cause)
|
|
? Effect.void
|
|
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
|
|
Effect.annotateLogs({ sessionID }),
|
|
),
|
|
),
|
|
Effect.asVoid,
|
|
)
|
|
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
|
|
// transaction as the started event. Terminals release it — except shutdown interruption, which
|
|
// preserves the claim so the next server start resumes the turn. A claim that survives with no
|
|
// terminal is the signature of a process that died without teardown (crash, SIGKILL, eviction);
|
|
// recovery is a property of the database, never of a shutdown hook that may not run.
|
|
const claimOnCommit = (sessionID: SessionSchema.ID) => ({
|
|
commit: () => store.claim(sessionID),
|
|
})
|
|
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
|
|
commit: () => store.release(sessionID),
|
|
})
|
|
function drain(
|
|
sessionID: SessionSchema.ID,
|
|
force: boolean,
|
|
continuation?: SessionRunner.Continuation,
|
|
): Effect.Effect<void, SessionRunner.RunError> {
|
|
return Effect.gen(function* () {
|
|
const session = yield* store.get(sessionID)
|
|
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
|
const result = yield* SessionRunner.Service.use((runner) =>
|
|
runner.drain({ sessionID, force, continuation }),
|
|
).pipe(
|
|
Effect.provide(locations.get(session.location)),
|
|
Effect.tapCause((cause) =>
|
|
Cause.hasInterruptsOnly(cause)
|
|
? Effect.void
|
|
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
|
|
),
|
|
)
|
|
if (result.type === "complete") return
|
|
return yield* drain(sessionID, false, result.continuation)
|
|
})
|
|
}
|
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
|
started: (sessionID) =>
|
|
reportLifecycle(
|
|
sessionID,
|
|
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
|
),
|
|
drain: (sessionID, force) => drain(sessionID, force),
|
|
// One terminal observation per busy period, covering every coalesced drain.
|
|
settled: (sessionID, exit, reason) =>
|
|
reportLifecycle(
|
|
sessionID,
|
|
Effect.gen(function* () {
|
|
const outcome = terminal(exit, reason)
|
|
if (outcome.type === "succeeded") {
|
|
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
|
|
return
|
|
}
|
|
if (outcome.type === "interrupted") {
|
|
// A user cancel (or a superseding execution) releases the claim: the turn must not
|
|
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
|
|
yield* bus.publish(
|
|
SessionEvent.Execution.Interrupted,
|
|
{ sessionID, reason: outcome.reason },
|
|
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
|
|
)
|
|
return
|
|
}
|
|
yield* bus.publish(
|
|
SessionEvent.Execution.Failed,
|
|
{
|
|
sessionID,
|
|
error: outcome.error,
|
|
},
|
|
releaseOnCommit(sessionID),
|
|
)
|
|
}),
|
|
),
|
|
})
|
|
yield* bus.subscribe(SessionEvent.Moved).pipe(
|
|
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
|
|
Effect.forkScoped,
|
|
)
|
|
|
|
return Service.of({
|
|
active: coordinator.active,
|
|
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
|
resume: coordinator.run,
|
|
wake: coordinator.wake,
|
|
awaitIdle: coordinator.awaitIdle,
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const node = makeGlobalNode({
|
|
service: Service,
|
|
layer,
|
|
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
|
|
})
|
|
|
|
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
|
export const noopLayer = Layer.succeed(
|
|
Service,
|
|
Service.of({
|
|
active: Effect.succeed(new Set()),
|
|
resume: () => Effect.void,
|
|
wake: () => Effect.void,
|
|
interrupt: () => Effect.void,
|
|
awaitIdle: () => Effect.void,
|
|
}),
|
|
)
|