feat(core): interrupt v2 session execution (#30850)
This commit is contained in:
parent
41bd9124f4
commit
12e38866ed
19 changed files with 1047 additions and 112 deletions
|
|
@ -40,7 +40,6 @@ import { LLMClient } from "@opencode-ai/llm"
|
|||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionRunCoordinator } from "./session/run-coordinator"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
|
|
@ -87,7 +86,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
Layer.provide(model),
|
||||
Layer.provide(skillGuidance),
|
||||
)
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
return Layer.mergeAll(
|
||||
services,
|
||||
commits,
|
||||
|
|
@ -97,7 +95,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
questions,
|
||||
model,
|
||||
runner,
|
||||
coordinator,
|
||||
builtInTools,
|
||||
).pipe(Layer.fresh)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export const layer = Layer.effect(
|
|||
}),
|
||||
get: sessions.get,
|
||||
list: sessions.list,
|
||||
interrupt: sessions.interrupt,
|
||||
prompt: (input) =>
|
||||
sessions.prompt({
|
||||
id: input.id,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export interface Interface {
|
|||
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
||||
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
|
||||
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as SessionV2 from "./session"
|
||||
export * from "./session/schema"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
|
@ -155,6 +155,7 @@ export interface Interface {
|
|||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
|
||||
|
|
@ -171,13 +172,13 @@ export const layer = Layer.effect(
|
|||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const scope = yield* Effect.scope
|
||||
|
||||
const enqueueWake = (sessionID: SessionSchema.ID) =>
|
||||
execution.wake(sessionID).pipe(
|
||||
const enqueueWake = (admitted: SessionInput.Admitted) =>
|
||||
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to wake Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("sessionID", admitted.sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
),
|
||||
|
|
@ -351,7 +352,7 @@ export const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
yield* result.get(input.sessionID)
|
||||
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
|
||||
if (input.resume !== false) yield* enqueueWake(input.sessionID)
|
||||
if (input.resume !== false) yield* enqueueWake(admitted)
|
||||
return admitted
|
||||
}, Effect.uninterruptible)
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
|
|
@ -399,6 +400,20 @@ export const layer = Layer.effect(
|
|||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* execution.interrupt(sessionID)
|
||||
const event = yield* events.publish(SessionEvent.InterruptRequested, {
|
||||
sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
})
|
||||
if (event.seq === undefined) return yield* Effect.die("Interrupt request event is missing aggregate sequence")
|
||||
yield* execution.interrupt(sessionID, event.seq)
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -119,6 +119,13 @@ export namespace PromptLifecycle {
|
|||
export type Promoted = typeof Promoted.Type
|
||||
}
|
||||
|
||||
export const InterruptRequested = EventV2.define({
|
||||
type: "session.next.interrupt.requested",
|
||||
...options,
|
||||
schema: Base,
|
||||
})
|
||||
export type InterruptRequested = typeof InterruptRequested.Type
|
||||
|
||||
export const ContextUpdated = EventV2.define({
|
||||
type: "session.next.context.updated",
|
||||
...options,
|
||||
|
|
@ -455,6 +462,7 @@ const DurableDefinitions = [
|
|||
Prompted,
|
||||
PromptLifecycle.Admitted,
|
||||
PromptLifecycle.Promoted,
|
||||
InterruptRequested,
|
||||
ContextUpdated,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,16 @@ export interface Interface {
|
|||
/** Explicitly drain one Session, making at least one provider attempt. */
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => 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/v2/SessionExecution") {}
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
export const noopLayer = Layer.succeed(Service, Service.of({ resume: () => Effect.void, wake: () => Effect.void }))
|
||||
export const noopLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Effect, Layer } from "effect"
|
||||
import { LocationServiceMap } from "../../location-layer"
|
||||
import { SessionRunCoordinator } from "../run-coordinator"
|
||||
import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
|
|
@ -11,25 +12,25 @@ export const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap
|
||||
const scope = yield* Effect.scope
|
||||
const withCoordinator = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: SessionSchema.ID,
|
||||
use: (coordinator: SessionRunCoordinator.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
return yield* SessionRunCoordinator.Service.use(use).pipe(Effect.provide(locations.get(session.location)))
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
|
||||
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
onFailure: (sessionID, cause) =>
|
||||
Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
resume: Effect.fn("SessionExecution.resume")(function* (sessionID) {
|
||||
return yield* withCoordinator(sessionID, (coordinator) => coordinator.run(sessionID))
|
||||
}),
|
||||
wake: Effect.fn("SessionExecution.wake")(function* (sessionID) {
|
||||
yield* withCoordinator(sessionID, (coordinator) =>
|
||||
coordinator.wake(sessionID).pipe(Effect.andThen(coordinator.awaitIdle(sessionID))),
|
||||
).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
}),
|
||||
interrupt: coordinator.interrupt,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.prompt.promoted": () => Effect.void,
|
||||
"session.next.interrupt.requested": () => Effect.void,
|
||||
"session.next.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
new SessionMessage.System({
|
||||
|
|
|
|||
|
|
@ -428,6 +428,7 @@ export const layer = Layer.effectDiscard(
|
|||
)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
|
||||
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
||||
if (!event.replay || event.seq === undefined) return run(db, event)
|
||||
return run(db, event).pipe(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
export * as SessionRunCoordinator from "./run-coordinator"
|
||||
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Scope } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export type Mode = "run" | "wake"
|
||||
|
||||
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
|
||||
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
|
||||
|
||||
/**
|
||||
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
|
||||
*
|
||||
|
|
@ -18,24 +21,44 @@ export type Mode = "run" | "wake"
|
|||
*
|
||||
* `wake` reports that durable work may now be available. It starts a chain while idle or
|
||||
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
|
||||
*
|
||||
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
|
||||
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
|
||||
*/
|
||||
export interface Coordinator<Key, A, E> {
|
||||
/** Starts or joins one explicit drain generation. */
|
||||
readonly run: (key: Key) => Effect.Effect<A, E>
|
||||
/** Coalesces one wake-up after durable work is recorded. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
|
||||
/** Waits until the current ownership chain settles. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
|
||||
/** Interrupts the active ownership chain without automatically draining pending wakes. */
|
||||
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
|
||||
type Entry<A, E> = {
|
||||
readonly done: Deferred.Deferred<A, E>
|
||||
mode: Mode
|
||||
rerun?: Mode
|
||||
explicit?: Deferred.Deferred<A, E>
|
||||
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
|
||||
current: Demand
|
||||
pending?: Demand
|
||||
explicitWaiter?: Deferred.Deferred<A, E>
|
||||
interruptSeq?: number
|
||||
owner?: Fiber.Fiber<void, never>
|
||||
stopping: boolean
|
||||
}
|
||||
|
||||
const strongest = (left: Mode | undefined, right: Mode): Mode => (left === "run" || right === "run" ? "run" : "wake")
|
||||
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
|
||||
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
|
||||
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
|
||||
return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
|
||||
}
|
||||
|
||||
const maxSeq = (left: number | undefined, right: number | undefined) => {
|
||||
if (left === undefined) return right
|
||||
if (right === undefined) return left
|
||||
return Math.max(left, right)
|
||||
}
|
||||
|
||||
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
|
||||
export const make = <Key, A, E>(options: {
|
||||
|
|
@ -44,7 +67,8 @@ export const make = <Key, A, E>(options: {
|
|||
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<Key, Entry<A, E>>()
|
||||
const scope = yield* Effect.scope
|
||||
const interruptSeq = new Map<Key, number>()
|
||||
const report = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const shutdown = Deferred.makeUnsafe<void>()
|
||||
let closed = false
|
||||
|
|
@ -53,67 +77,97 @@ export const make = <Key, A, E>(options: {
|
|||
closed = true
|
||||
Deferred.doneUnsafe(shutdown, Effect.void)
|
||||
active.clear()
|
||||
interruptSeq.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
const makeEntry = (mode: Mode, explicit?: Deferred.Deferred<A, E>): Entry<A, E> => ({
|
||||
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
|
||||
done: Deferred.makeUnsafe<A, E>(),
|
||||
mode,
|
||||
explicit,
|
||||
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
|
||||
current,
|
||||
explicitWaiter,
|
||||
stopping: false,
|
||||
})
|
||||
|
||||
const start = (key: Key, entry: Entry<A, E>, mode: Mode) => {
|
||||
fork(own(key, entry, mode))
|
||||
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
|
||||
const ready = Deferred.makeUnsafe<void>()
|
||||
const drain = Effect.suspend(() => options.drain(key, demand._tag))
|
||||
// Initial work retains immediate-start behavior but cannot run before ownership is published.
|
||||
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
|
||||
const owner = fork(
|
||||
(successor ? Effect.yieldNow.pipe(Effect.andThen(drain)) : Deferred.await(ready).pipe(Effect.andThen(drain))).pipe(
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
entry.owner = owner
|
||||
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
||||
}
|
||||
|
||||
const own = (key: Key, entry: Entry<A, E>, mode: Mode): Effect.Effect<void> =>
|
||||
Effect.suspend(() => options.drain(key, mode)).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => {
|
||||
if (closed) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
|
||||
if (mode === "run" && entry.explicit !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicit, exit)
|
||||
entry.explicit = undefined
|
||||
}
|
||||
if (exit._tag === "Success") {
|
||||
if (active.get(key) !== entry) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
|
||||
if (entry.rerun !== undefined) {
|
||||
const mode = entry.rerun
|
||||
entry.rerun = undefined
|
||||
entry.mode = mode
|
||||
return own(key, entry, mode)
|
||||
}
|
||||
active.delete(key)
|
||||
return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
|
||||
}
|
||||
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
|
||||
if (closed) {
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
return
|
||||
}
|
||||
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicitWaiter, exit)
|
||||
entry.explicitWaiter = undefined
|
||||
}
|
||||
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
|
||||
Deferred.doneUnsafe(entry.explicitWaiter, exit)
|
||||
entry.explicitWaiter = undefined
|
||||
}
|
||||
if (active.get(key) !== entry) {
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
return
|
||||
}
|
||||
if (exit._tag === "Success" && !entry.stopping) {
|
||||
if (entry.pending !== undefined) {
|
||||
const pending = entry.pending
|
||||
entry.pending = undefined
|
||||
entry.current = pending
|
||||
start(key, entry, pending, true)
|
||||
return
|
||||
}
|
||||
active.delete(key)
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
return
|
||||
}
|
||||
|
||||
const successor =
|
||||
active.get(key) === entry && entry.rerun !== undefined ? makeEntry(entry.rerun, entry.explicit) : undefined
|
||||
if (successor === undefined) active.delete(key)
|
||||
else {
|
||||
active.set(key, successor)
|
||||
}
|
||||
if (successor !== undefined) start(key, successor, successor.mode)
|
||||
const report =
|
||||
mode === "wake" && options.onFailure !== undefined
|
||||
? options.onFailure(key, exit.cause).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
: Effect.void
|
||||
return Deferred.done(entry.done, exit).pipe(Effect.andThen(report), Effect.asVoid)
|
||||
}),
|
||||
)
|
||||
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
|
||||
if (successor === undefined) active.delete(key)
|
||||
else active.set(key, successor)
|
||||
if (successor !== undefined) start(key, successor, successor.current, true)
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
|
||||
if (
|
||||
exit._tag === "Failure" &&
|
||||
!(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) &&
|
||||
demand._tag === "wake" &&
|
||||
options.onFailure !== undefined
|
||||
) {
|
||||
report(Effect.suspend(() => options.onFailure!(key, exit.cause)))
|
||||
}
|
||||
}
|
||||
|
||||
const wake = (key: Key) =>
|
||||
const wake = (key: Key, seq?: number) =>
|
||||
Effect.sync(() => {
|
||||
if (closed) return
|
||||
if (!isAfterInterrupt(key, seq)) return
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
entry.rerun = strongest(entry.rerun, "wake")
|
||||
if (!acceptsWake(entry, seq)) return
|
||||
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
|
||||
return
|
||||
}
|
||||
|
||||
const next = makeEntry("wake")
|
||||
const next = makeEntry({ _tag: "wake", seq })
|
||||
active.set(key, next)
|
||||
start(key, next, "wake")
|
||||
start(key, next, next.current)
|
||||
})
|
||||
|
||||
const awaitIdle = (key: Key): Effect.Effect<void, E> =>
|
||||
|
|
@ -123,7 +177,7 @@ export const make = <Key, A, E>(options: {
|
|||
const entry = active.get(key)
|
||||
if (entry === undefined) break
|
||||
const exit = yield* Effect.raceFirst(
|
||||
Deferred.await(entry.done).pipe(Effect.exit),
|
||||
Deferred.await(entry.settled),
|
||||
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
|
||||
)
|
||||
if (closed) break
|
||||
|
|
@ -132,24 +186,48 @@ export const make = <Key, A, E>(options: {
|
|||
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
|
||||
})
|
||||
|
||||
return { run, wake, awaitIdle }
|
||||
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
const latest = interruptSeq.get(key)
|
||||
if (seq !== undefined && latest !== undefined && seq <= latest)
|
||||
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
|
||||
if (seq !== undefined) interruptSeq.set(key, seq)
|
||||
if (entry?.owner === undefined) return Effect.void
|
||||
if (seq !== undefined && entry.current._tag === "wake" && entry.current.seq !== undefined && entry.current.seq > seq)
|
||||
return Effect.void
|
||||
if (entry.stopping) {
|
||||
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
|
||||
suppressPendingAtOrBefore(entry, seq)
|
||||
return Fiber.interrupt(entry.owner)
|
||||
}
|
||||
entry.stopping = true
|
||||
entry.interruptSeq = seq
|
||||
suppressPendingAtOrBefore(entry, seq)
|
||||
return Fiber.interrupt(entry.owner)
|
||||
})
|
||||
|
||||
return { run, wake, awaitIdle, interrupt }
|
||||
|
||||
function run(key: Key): Effect.Effect<A, E> {
|
||||
return Effect.uninterruptibleMask((restore) => {
|
||||
if (closed) return Effect.interrupt
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
if (entry.mode === "wake") {
|
||||
entry.rerun = "run"
|
||||
entry.explicit ??= Deferred.makeUnsafe<A, E>()
|
||||
return restore(awaitRun(entry.explicit))
|
||||
if (entry.stopping) {
|
||||
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
|
||||
}
|
||||
if (entry.current._tag === "wake") {
|
||||
entry.pending = coalesce(entry.pending, { _tag: "run" })
|
||||
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
|
||||
return restore(awaitRun(entry.explicitWaiter))
|
||||
}
|
||||
return restore(awaitRun(entry.done))
|
||||
}
|
||||
|
||||
const next = makeEntry("run")
|
||||
const next = makeEntry({ _tag: "run" })
|
||||
active.set(key, next)
|
||||
start(key, next, "run")
|
||||
start(key, next, next.current)
|
||||
return restore(awaitRun(next.done))
|
||||
})
|
||||
}
|
||||
|
|
@ -157,6 +235,21 @@ export const make = <Key, A, E>(options: {
|
|||
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
|
||||
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
|
||||
}
|
||||
|
||||
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
|
||||
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
|
||||
}
|
||||
|
||||
function isAfterInterrupt(key: Key, seq: number | undefined) {
|
||||
const latest = interruptSeq.get(key)
|
||||
return latest === undefined || (seq !== undefined && seq > latest)
|
||||
}
|
||||
|
||||
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
|
||||
if (entry.pending?._tag === "wake" && seq !== undefined && entry.pending.seq !== undefined && entry.pending.seq > seq)
|
||||
return
|
||||
entry.pending = undefined
|
||||
}
|
||||
})
|
||||
|
||||
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
|
||||
|
|
@ -165,19 +258,17 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const runner = yield* SessionRunner.Service
|
||||
return Service.of(
|
||||
yield* make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
SessionRunner.Service.pipe(
|
||||
Effect.flatMap((runner) =>
|
||||
make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
|
||||
onFailure: (sessionID, cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.map(Service.of),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Cause, DateTime, Effect, FiberSet, Layer, Schema, Semaphore, Stream } f
|
|||
import { AgentV2 } from "../../agent"
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { Location } from "../../location"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { QuestionV2 } from "../../question"
|
||||
|
|
@ -82,6 +83,7 @@ export const layer = Layer.effect(
|
|||
const tools = yield* ToolRegistry.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const location = yield* Location.Service
|
||||
const systemContext = yield* SystemContextRegistry.Service
|
||||
const skillGuidance = yield* SkillGuidance.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
|
@ -144,6 +146,8 @@ export const layer = Layer.effect(
|
|||
promotion: SessionInput.Delivery | undefined,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(
|
||||
db,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue