chore(core): merge dev into session subpath fix

This commit is contained in:
Kit Langton 2026-06-22 13:19:06 -04:00
commit 2d550aac46
22 changed files with 220 additions and 1384 deletions

View file

@ -1,7 +1,7 @@
export * as SessionV2 from "./session" export * as SessionV2 from "./session"
export * from "./session/schema" export * from "./session/schema"
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm" import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project" import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace" import { WorkspaceV2 } from "./workspace"
@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index" import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store" import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution" import { SessionExecution } from "./session/execution"
import { logFailure } from "./session/logging"
import { MessageDecodeError } from "./session/error" import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input" import { SessionInput } from "./session/input"
@ -168,20 +167,6 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const scope = yield* Effect.scope
const enqueueWake = (admitted: SessionInput.Admitted) =>
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: logFailure("Failed to wake Session", admitted.sessionID, cause),
),
Effect.ignore,
Effect.forkIn(scope, { startImmediately: true }),
Effect.asVoid,
)
const decode = (row: typeof SessionMessageTable.$inferSelect) => const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError( Effect.mapError(
@ -349,10 +334,6 @@ export const layer = Layer.effect(
Effect.uninterruptible( Effect.uninterruptible(
Effect.gen(function* () { Effect.gen(function* () {
yield* result.get(input.sessionID) yield* result.get(input.sessionID)
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
if (input.resume !== false) yield* enqueueWake(admitted)
return admitted
}, Effect.uninterruptible)
const messageID = input.id ?? SessionMessage.ID.create() const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer" const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery } const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
@ -370,7 +351,8 @@ export const layer = Layer.effect(
) )
if (!SessionInput.equivalent(admitted, expected)) if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
return yield* returnPrompt(admitted) if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}), }),
), ),
), ),
@ -411,19 +393,7 @@ export const layer = Layer.effect(
yield* execution.resume(sessionID) yield* execution.resume(sessionID)
}), }),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) => interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible( Effect.uninterruptible(execution.interrupt(sessionID)),
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.durable === undefined)
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
yield* execution.interrupt(sessionID, event.durable.seq)
}),
),
), ),
}) })

View file

@ -118,13 +118,6 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type 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({ export const ContextUpdated = EventV2.define({
type: "session.next.context.updated", type: "session.next.context.updated",
...options, ...options,
@ -475,7 +468,6 @@ const DurableDefinitions = [
Prompted, Prompted,
PromptLifecycle.Admitted, PromptLifecycle.Admitted,
PromptLifecycle.Promoted, PromptLifecycle.Promoted,
InterruptRequested,
ContextUpdated, ContextUpdated,
Synthetic, Synthetic,
Shell.Started, Shell.Started,

View file

@ -5,12 +5,12 @@ import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
export interface Interface { export interface Interface {
/** Explicitly drain one Session, making at least one provider attempt. */ /** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */ /** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError> readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */ /** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
} }
/** Routes execution from a Session ID to the runner owned by that Session's Location. */ /** Routes execution from a Session ID to the runner owned by that Session's Location. */

View file

@ -1,11 +1,10 @@
import { Effect, Layer } from "effect" import { Cause, Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-layer" import { LocationServiceMap } from "../../location-layer"
import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunCoordinator } from "../run-coordinator"
import { SessionRunner } from "../runner" import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionStore } from "../store" import { SessionStore } from "../store"
import { SessionExecution } from "../execution" import { SessionExecution } from "../execution"
import { logFailure } from "../logging"
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
export const layer = Layer.effect( export const layer = Layer.effect(
@ -13,15 +12,19 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap const locations = yield* LocationServiceMap
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({ const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) { drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe( return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force })).pipe(
Effect.provide(locations.get(session.location)), 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 })),
),
) )
}), }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}) })
return SessionExecution.Service.of({ return SessionExecution.Service.of({

View file

@ -1,8 +0,0 @@
import { Cause, Effect } from "effect"
import { SessionSchema } from "./schema"
export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))

View file

@ -138,7 +138,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.next.prompt.admitted": () => Effect.void, "session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void, "session.next.prompt.promoted": () => Effect.void,
"session.next.interrupt.requested": () => Effect.void,
"session.next.context.updated": (event) => "session.next.context.updated": (event) =>
adapter.appendMessage( adapter.appendMessage(
new SessionMessage.System({ new SessionMessage.System({

View file

@ -399,7 +399,6 @@ export const layer = Layer.effectDiscard(
) )
}), }),
) )
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))

View file

@ -1,106 +1,43 @@
export * as SessionRunCoordinator from "./run-coordinator" export * as SessionRunCoordinator from "./run-coordinator"
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect" import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
import { SessionRunner } from "./runner"
import { logFailure } from "./logging"
import { SessionSchema } from "./schema"
export type Mode = "run" | "wake" /** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E> {
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */ /** Starts execution while idle or joins the active execution. */
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number } readonly run: (key: Key) => Effect.Effect<void, E>
/** Registers one coalesced follow-up after newly recorded work. */
/** readonly wake: (key: Key) => Effect.Effect<void>
* Runs at most one drain chain per key while allowing different keys to drain concurrently. /** Stops active execution and waits for its cleanup. */
* readonly interrupt: (key: Key) => Effect.Effect<void>
* For each key:
*
* idle --run/wake--> draining --run/wake--> draining + one coalesced rerun --> idle
*
* `run` is an explicit drain request. It starts a chain or joins the current chain and
* upgrades a pending follow-up so the caller receives explicit-run semantics.
*
* `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, 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<E> = {
type Entry<A, E> = { readonly done: Deferred.Deferred<void, E>
readonly done: 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> owner?: Fiber.Fiber<void, never>
pendingWake: boolean
stopping: boolean stopping: boolean
} }
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */ export const make = <Key, E>(options: {
const coalesce = (left: Demand | undefined, right: Demand): Demand => { readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" } }): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
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: {
readonly drain: (key: Key, mode: Mode) => Effect.Effect<A, E>
readonly onFailure?: (key: Key, cause: Cause.Cause<E>) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
Effect.gen(function* () { Effect.gen(function* () {
const active = new Map<Key, Entry<A, E>>() const active = new Map<Key, Entry<E>>()
const interruptSeq = new Map<Key, number>()
const report = yield* FiberSet.makeRuntime<never, void, never>()
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
const shutdown = Deferred.makeUnsafe<void>()
let closed = false
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
closed = true
Deferred.doneUnsafe(shutdown, Effect.void)
active.clear()
interruptSeq.clear()
}),
)
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({ const makeEntry = (): Entry<E> => ({
done: Deferred.makeUnsafe<A, E>(), done: Deferred.makeUnsafe<void, E>(),
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(), pendingWake: false,
current,
explicitWaiter,
stopping: false, stopping: false,
}) })
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => { const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
const ready = Deferred.makeUnsafe<void>() 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( const owner = fork(
(successor (successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
? Effect.yieldNow.pipe(Effect.andThen(drain)) Effect.andThen(Effect.suspend(() => options.drain(key, force))),
: Deferred.await(ready).pipe(Effect.andThen(drain)) Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
).pipe(
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
Effect.exit, Effect.exit,
Effect.asVoid, Effect.asVoid,
), ),
@ -109,176 +46,57 @@ export const make = <Key, A, E>(options: {
if (!successor) Deferred.doneUnsafe(ready, Effect.void) if (!successor) Deferred.doneUnsafe(ready, Effect.void)
} }
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => { const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
if (closed) { if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
Deferred.doneUnsafe(entry.done, exit) entry.pendingWake = false
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit)) start(key, entry, false, true)
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 return
} }
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined const successor = entry.pendingWake ? makeEntry() : undefined
if (successor === undefined) active.delete(key) if (successor === undefined) active.delete(key)
else active.set(key, successor) else {
if (successor !== undefined) start(key, successor, successor.current, true) active.set(key, successor)
Deferred.doneUnsafe(entry.done, exit) start(key, successor, false, true)
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)))
} }
Deferred.doneUnsafe(entry.done, exit)
} }
const wake = (key: Key, seq?: number) => const run = (key: Key): Effect.Effect<void, E> =>
Effect.sync(() => { Effect.uninterruptibleMask((restore) => {
if (closed) return
if (!isAfterInterrupt(key, seq)) return
const entry = active.get(key) const entry = active.get(key)
if (entry !== undefined) { if (entry !== undefined) {
if (!acceptsWake(entry, seq)) return if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
entry.pending = coalesce(entry.pending, { _tag: "wake", seq }) return restore(Deferred.await(entry.done))
}
const next = makeEntry()
active.set(key, next)
start(key, next, true)
return restore(Deferred.await(next.done))
})
const wake = (key: Key) =>
Effect.sync(() => {
const entry = active.get(key)
if (entry !== undefined) {
entry.pendingWake = true
return return
} }
const next = makeEntry({ _tag: "wake", seq }) const next = makeEntry()
active.set(key, next) active.set(key, next)
start(key, next, next.current) start(key, next, false)
}) })
const awaitIdle = (key: Key): Effect.Effect<void, E> => const interrupt = (key: Key): Effect.Effect<void> =>
Effect.gen(function* () {
let firstFailure: Cause.Cause<E> | undefined
while (!closed) {
const entry = active.get(key)
if (entry === undefined) break
const exit = yield* Effect.raceFirst(
Deferred.await(entry.settled),
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
)
if (closed) break
if (exit._tag === "Failure" && firstFailure === undefined) firstFailure = exit.cause
}
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
})
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
Effect.suspend(() => { Effect.suspend(() => {
const entry = active.get(key) 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 (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.stopping = true
entry.interruptSeq = seq entry.pendingWake = false
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner) return Fiber.interrupt(entry.owner)
}) })
return { run, wake, awaitIdle, interrupt } return { run, wake, 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.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({ _tag: "run" })
active.set(key, next)
start(key, next, next.current)
return restore(awaitRun(next.done))
})
}
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> {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunCoordinator") {}
export const layer = Layer.effect(
Service,
SessionRunner.Service.pipe(
Effect.flatMap((runner) =>
make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
}),
),
Effect.map(Service.of),
),
)

View file

@ -21,7 +21,7 @@ export interface Interface {
/** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */ /** Drains eligible durable work. Explicit runs perform one provider attempt even when no work is eligible. */
readonly run: (input: { readonly run: (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force?: boolean readonly force: boolean
}) => Effect.Effect<void, RunError> }) => Effect.Effect<void, RunError>
} }

View file

@ -346,14 +346,14 @@ export const layer = Layer.effect(
const run = Effect.fn("SessionRunner.run")(function* (input: { const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force?: boolean readonly force: boolean
}) { }) {
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (input.force !== true && !hasSteer && !hasQueue) return if (!input.force && !hasSteer && !hasQueue) return
yield* failInterruptedTools(input.sessionID) yield* failInterruptedTools(input.sessionID)
let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let openActivity = input.force === true || hasSteer || hasQueue let openActivity = input.force || hasSteer || hasQueue
while (openActivity) { while (openActivity) {
let needsContinuation = true let needsContinuation = true
for (let step = 1; needsContinuation; step++) { for (let step = 1; needsContinuation; step++) {

View file

@ -1,30 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Logger } from "effect"
import { logFailure } from "@opencode-ai/core/session/logging"
import { SessionSchema } from "@opencode-ai/core/session/schema"
describe("Session logging", () => {
for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) {
test(`renders the cause for ${message}`, async () => {
const entries: Array<ReturnType<typeof Logger.formatStructured.log>> = []
const logger = Logger.formatStructured.pipe(
Logger.map((entry): void => {
entries.push(entry)
}),
)
await logFailure(
message,
SessionSchema.ID.make("session-123"),
Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }),
).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise)
expect(entries).toHaveLength(1)
expect(entries[0]?.message).toBe(message)
expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" })
expect(entries[0]?.cause).toContain("SessionFailure")
expect(entries[0]?.cause).toContain("nested-code")
expect(entries[0]?.cause).not.toContain("[Object")
})
}
})

View file

@ -20,9 +20,7 @@ import { testEffect } from "./lib/effect"
const executionCalls: SessionV2.ID[] = [] const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = [] const interruptCalls: SessionV2.ID[] = []
const interruptSeqs: Array<number | undefined> = []
const wakeCalls: SessionV2.ID[] = [] const wakeCalls: SessionV2.ID[] = []
const wakeSeqs: Array<number | undefined> = []
const execution = Layer.succeed( const execution = Layer.succeed(
SessionExecution.Service, SessionExecution.Service,
SessionExecution.Service.of({ SessionExecution.Service.of({
@ -30,15 +28,13 @@ const execution = Layer.succeed(
Effect.sync(() => { Effect.sync(() => {
executionCalls.push(sessionID) executionCalls.push(sessionID)
}), }),
interrupt: (sessionID, seq) => interrupt: (sessionID) =>
Effect.sync(() => { Effect.sync(() => {
interruptCalls.push(sessionID) interruptCalls.push(sessionID)
interruptSeqs.push(seq)
}), }),
wake: (sessionID, seq) => wake: (sessionID) =>
Effect.sync(() => { Effect.sync(() => {
wakeCalls.push(sessionID) wakeCalls.push(sessionID)
wakeSeqs.push(seq)
}), }),
}), }),
) )
@ -109,15 +105,6 @@ const eventCount = (type: string) =>
), ),
) )
const interruptEvent = Database.Service.use(({ db }) =>
db
.select()
.from(EventTable)
.where(eq(EventTable.type, "session.next.interrupt.requested.1"))
.get()
.pipe(Effect.orDie),
)
describe("SessionV2.prompt", () => { describe("SessionV2.prompt", () => {
it.effect("delegates execution continuation through SessionExecution", () => it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () { Effect.gen(function* () {
@ -131,19 +118,14 @@ describe("SessionV2.prompt", () => {
}), }),
) )
it.effect("delegates interruption through SessionExecution", () => it.effect("delegates process-local interruption through SessionExecution", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
interruptCalls.length = 0 interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(sessionID) yield* session.interrupt(sessionID)
expect(interruptCalls).toEqual([sessionID]) expect(interruptCalls).toEqual([sessionID])
expect(interruptSeqs).toHaveLength(1)
expect(typeof interruptSeqs[0]).toBe("number")
expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(1)
expect(yield* interruptEvent).toMatchObject({ aggregate_id: sessionID, seq: interruptSeqs[0] })
expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* session.messages({ sessionID })).toEqual([])
}), }),
) )
@ -152,11 +134,9 @@ describe("SessionV2.prompt", () => {
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
interruptCalls.length = 0 interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(SessionV2.ID.make("ses_missing")) yield* session.interrupt(SessionV2.ID.make("ses_missing"))
expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")]) expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")])
expect(interruptSeqs).toEqual([undefined])
}), }),
) )
@ -515,13 +495,11 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
executionCalls.length = 0 executionCalls.length = 0
wakeCalls.length = 0 wakeCalls.length = 0
wakeSeqs.length = 0
const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
expect(executionCalls).toEqual([]) expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID]) expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}), }),
) )
@ -531,9 +509,8 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
executionCalls.length = 0 executionCalls.length = 0
wakeCalls.length = 0 wakeCalls.length = 0
wakeSeqs.length = 0
const admitted = yield* session.prompt({ yield* session.prompt({
sessionID, sessionID,
prompt: new Prompt({ text: "Run explicitly" }), prompt: new Prompt({ text: "Run explicitly" }),
resume: true, resume: true,
@ -541,7 +518,6 @@ describe("SessionV2.prompt", () => {
expect(executionCalls).toEqual([]) expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID]) expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}), }),
) )
@ -551,13 +527,11 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
executionCalls.length = 0 executionCalls.length = 0
wakeCalls.length = 0 wakeCalls.length = 0
wakeSeqs.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false }) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false })
expect(executionCalls).toEqual([]) expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([]) expect(wakeCalls).toEqual([])
expect(wakeSeqs).toEqual([])
}), }),
) )
}) })

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
@ -83,19 +84,20 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(referenceGuidance), Layer.provide(referenceGuidance),
Layer.provide(config), Layer.provide(config),
) )
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect( const execution = Layer.effect(
SessionExecution.Service, SessionExecution.Service,
SessionRunCoordinator.Service.pipe( Effect.gen(function* () {
Effect.map((coordinator) => const sessionRunner = yield* SessionRunner.Service
SessionExecution.Service.of({ const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
resume: coordinator.run, drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
wake: coordinator.wake, })
interrupt: coordinator.interrupt, return SessionExecution.Service.of({
}), resume: coordinator.run,
), wake: coordinator.wake,
), interrupt: coordinator.interrupt,
).pipe(Layer.provide(coordinator)) })
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
@ -120,7 +122,6 @@ const it = testEffect(
skillGuidance, skillGuidance,
config, config,
runner, runner,
coordinator,
execution, execution,
sessions, sessions,
), ),

View file

@ -244,19 +244,20 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(referenceGuidance), Layer.provide(referenceGuidance),
Layer.provide(config), Layer.provide(config),
) )
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect( const execution = Layer.effect(
SessionExecution.Service, SessionExecution.Service,
SessionRunCoordinator.Service.pipe( Effect.gen(function* () {
Effect.map((coordinator) => const sessionRunner = yield* SessionRunner.Service
SessionExecution.Service.of({ const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
resume: coordinator.run, drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
wake: coordinator.wake, })
interrupt: coordinator.interrupt, return SessionExecution.Service.of({
}), resume: coordinator.run,
), wake: coordinator.wake,
), interrupt: coordinator.interrupt,
).pipe(Layer.provide(coordinator)) })
}),
).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
@ -283,7 +284,6 @@ const it = testEffect(
skillGuidance, skillGuidance,
config, config,
runner, runner,
coordinator,
execution, execution,
sessions, sessions,
), ),
@ -681,7 +681,6 @@ describe("SessionRunnerLLM", () => {
systemUnavailable = false systemUnavailable = false
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) }) yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID)
expect(requests).toHaveLength(1) expect(requests).toHaveLength(1)
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"]) expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
@ -2161,7 +2160,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"]) expect(userTexts(requests[1]!)).toEqual(["Start working", "First steer", "Second steer"])
yield* (yield* SessionRunCoordinator.Service).wake(sessionID) yield* (yield* SessionExecution.Service).wake(sessionID)
yield* Effect.yieldNow yield* Effect.yieldNow
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
}), }),
@ -2367,7 +2366,7 @@ describe("SessionRunnerLLM", () => {
}) })
requests.length = 0 requests.length = 0
yield* (yield* SessionRunCoordinator.Service).wake(sessionID) yield* (yield* SessionExecution.Service).wake(sessionID)
yield* Effect.yieldNow yield* Effect.yieldNow
expect(requests).toHaveLength(1) expect(requests).toHaveLength(1)
@ -2417,7 +2416,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.finish({ reason: "stop" }), LLMEvent.finish({ reason: "stop" }),
] ]
yield* (yield* SessionRunCoordinator.Service).wake(sessionID) yield* (yield* SessionExecution.Service).wake(sessionID)
while (requests.length === 0) yield* Effect.yieldNow while (requests.length === 0) yield* Effect.yieldNow
expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"]) expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])

View file

@ -13,6 +13,7 @@ const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455 const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
interface PkceCodes { interface PkceCodes {
verifier: string verifier: string
@ -370,6 +371,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
Object.entries(provider.models) Object.entries(provider.models)
.filter(([, model]) => { .filter(([, model]) => {
if (ALLOWED_MODELS.has(model.api.id)) return true if (ALLOWED_MODELS.has(model.api.id)) return true
if (DISALLOWED_MODELS.has(model.api.id)) return false
const match = model.api.id.match(/^gpt-(\d+\.\d+)/) const match = model.api.id.match(/^gpt-(\d+\.\d+)/)
return match ? parseFloat(match[1]) > 5.4 : false return match ? parseFloat(match[1]) > 5.4 : false
}) })

View file

@ -21,7 +21,6 @@ export type Event =
| EventSessionNextPrompted | EventSessionNextPrompted
| EventSessionNextPromptAdmitted | EventSessionNextPromptAdmitted
| EventSessionNextPromptPromoted | EventSessionNextPromptPromoted
| EventSessionNextInterruptRequested
| EventSessionNextContextUpdated | EventSessionNextContextUpdated
| EventSessionNextSynthetic | EventSessionNextSynthetic
| EventSessionNextShellStarted | EventSessionNextShellStarted
@ -876,14 +875,6 @@ export type GlobalEvent = {
timeCreated: number timeCreated: number
} }
} }
| {
id: string
type: "session.next.interrupt.requested"
properties: {
timestamp: number
sessionID: string
}
}
| { | {
id: string id: string
type: "session.next.context.updated" type: "session.next.context.updated"
@ -1638,7 +1629,6 @@ export type GlobalEvent = {
| SyncEventSessionNextPrompted | SyncEventSessionNextPrompted
| SyncEventSessionNextPromptAdmitted | SyncEventSessionNextPromptAdmitted
| SyncEventSessionNextPromptPromoted | SyncEventSessionNextPromptPromoted
| SyncEventSessionNextInterruptRequested
| SyncEventSessionNextContextUpdated | SyncEventSessionNextContextUpdated
| SyncEventSessionNextSynthetic | SyncEventSessionNextSynthetic
| SyncEventSessionNextShellStarted | SyncEventSessionNextShellStarted
@ -2781,7 +2771,6 @@ export type V2Event =
| V2EventSessionNextPrompted | V2EventSessionNextPrompted
| V2EventSessionNextPromptAdmitted | V2EventSessionNextPromptAdmitted
| V2EventSessionNextPromptPromoted | V2EventSessionNextPromptPromoted
| V2EventSessionNextInterruptRequested
| V2EventSessionNextContextUpdated | V2EventSessionNextContextUpdated
| V2EventSessionNextSynthetic | V2EventSessionNextSynthetic
| V2EventSessionNextShellStarted | V2EventSessionNextShellStarted
@ -3249,21 +3238,6 @@ export type SyncEventSessionNextPromptPromoted = {
} }
} }
export type SyncEventSessionNextInterruptRequested = {
type: "sync"
id: string
syncEvent: {
type: "session.next.interrupt.requested.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
}
}
}
export type SyncEventSessionNextContextUpdated = { export type SyncEventSessionNextContextUpdated = {
type: "sync" type: "sync"
id: string id: string
@ -4570,24 +4544,6 @@ export type V2EventSessionNextPromptPromoted = {
} }
} }
export type V2EventSessionNextInterruptRequested = {
id: string
metadata?: {
[key: string]: unknown
}
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
type: "session.next.interrupt.requested"
data: {
timestamp: number
sessionID: string
}
}
export type V2EventSessionNextContextUpdated = { export type V2EventSessionNextContextUpdated = {
id: string id: string
metadata?: { metadata?: {
@ -6224,15 +6180,6 @@ export type EventSessionNextPromptPromoted = {
} }
} }
export type EventSessionNextInterruptRequested = {
id: string
type: "session.next.interrupt.requested"
properties: {
timestamp: number
sessionID: string
}
}
export type EventSessionNextContextUpdated = { export type EventSessionNextContextUpdated = {
id: string id: string
type: "session.next.context.updated" type: "session.next.context.updated"

View file

@ -14689,9 +14689,6 @@
{ {
"$ref": "#/components/schemas/EventSessionNextPromptPromoted" "$ref": "#/components/schemas/EventSessionNextPromptPromoted"
}, },
{
"$ref": "#/components/schemas/EventSessionNextInterruptRequested"
},
{ {
"$ref": "#/components/schemas/EventSessionNextContextUpdated" "$ref": "#/components/schemas/EventSessionNextContextUpdated"
}, },
@ -17297,35 +17294,6 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "additionalProperties": false
}, },
{
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
{ {
"type": "object", "type": "object",
"properties": { "properties": {
@ -19867,9 +19835,6 @@
{ {
"$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted"
}, },
{
"$ref": "#/components/schemas/SyncEventSessionNextInterruptRequested"
},
{ {
"$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated"
}, },
@ -23111,9 +23076,6 @@
{ {
"$ref": "#/components/schemas/V2EventSessionNextPromptPromoted" "$ref": "#/components/schemas/V2EventSessionNextPromptPromoted"
}, },
{
"$ref": "#/components/schemas/V2EventSessionNextInterruptRequested"
},
{ {
"$ref": "#/components/schemas/V2EventSessionNextContextUpdated" "$ref": "#/components/schemas/V2EventSessionNextContextUpdated"
}, },
@ -24498,56 +24460,6 @@
"required": ["type", "id", "syncEvent"], "required": ["type", "id", "syncEvent"],
"additionalProperties": false "additionalProperties": false
}, },
"SyncEventSessionNextInterruptRequested": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["sync"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"syncEvent": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested.1"]
},
"id": {
"type": "string",
"pattern": "^evt_"
},
"seq": {
"type": "number"
},
"aggregateID": {
"type": "string"
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["type", "id", "seq", "aggregateID", "data"],
"additionalProperties": false
}
},
"required": ["type", "id", "syncEvent"],
"additionalProperties": false
},
"SyncEventSessionNextContextUpdated": { "SyncEventSessionNextContextUpdated": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -28771,57 +28683,6 @@
"required": ["id", "type", "data"], "required": ["id", "type", "data"],
"additionalProperties": false "additionalProperties": false
}, },
"V2EventSessionNextInterruptRequested": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"metadata": {
"type": "object"
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer"
},
"version": {
"type": "integer"
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/LocationRef"
},
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested"]
},
"data": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "data"],
"additionalProperties": false
},
"V2EventSessionNextContextUpdated": { "V2EventSessionNextContextUpdated": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -33460,35 +33321,6 @@
"required": ["id", "type", "properties"], "required": ["id", "type", "properties"],
"additionalProperties": false "additionalProperties": false
}, },
"EventSessionNextInterruptRequested": {
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^evt_"
},
"type": {
"type": "string",
"enum": ["session.next.interrupt.requested"]
},
"properties": {
"type": "object",
"properties": {
"timestamp": {
"type": "number"
},
"sessionID": {
"type": "string",
"pattern": "^ses"
}
},
"required": ["timestamp", "sessionID"],
"additionalProperties": false
}
},
"required": ["id", "type", "properties"],
"additionalProperties": false
},
"EventSessionNextContextUpdated": { "EventSessionNextContextUpdated": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -744,10 +744,14 @@ function TopModelsChart(props: {
data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"} data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"}
> >
<strong>{point().date}</strong> <strong>{point().date}</strong>
<span> <Show when={metric() === "tokens"}>
{formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} <span>
</span> {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())}
<div data-slot="tooltip-divider" /> </span>
</Show>
<Show when={metric() === "tokens"}>
<div data-slot="tooltip-divider" />
</Show>
<For each={visibleTopModelsSegments(point())}> <For each={visibleTopModelsSegments(point())}>
{(item) => ( {(item) => (
<p <p

View file

@ -1,5 +1,10 @@
# V2 Schema Changelog # V2 Schema Changelog
## 2026-06-22: Make Session Interruption Process-Local
- Remove the unprojected `session.next.interrupt.requested.1` event from the experimental durable Session event union and generated SDK.
- No canonical V1 data requires migration; experimental V2 event history containing the retired event is disposable.
## 2026-06-05: Execute Automatic Session Compaction ## 2026-06-05: Execute Automatic Session Compaction
- Trigger automatic compaction before provider turns using the complete estimated request and absolute model-aware headroom. - Trigger automatic compaction before provider turns using the complete estimated request and absolute model-aware headroom.

View file

@ -20,10 +20,10 @@ sessions.prompt({ id?, sessionID, prompt, delivery?, resume? })
-> resume false admits only -> resume false admits only
sessions.interrupt(sessionID) sessions.interrupt(sessionID)
-> interrupts the active ownership chain on this process -> interrupts active execution on this process
-> waits for active drain cleanup and settlement -> waits for runner cleanup and settlement
-> suppresses reruns already queued before interruption -> clears a coalesced follow-up wake already registered with this coordinator
-> preserves durable inbox rows for a later fresh wake or resume -> preserves durable inbox rows for a later wake or resume
-> idle or missing Session is a no-op -> idle or missing Session is a no-op
``` ```
@ -152,12 +152,12 @@ Inbox delivery is explicit:
Execution has two entry points: Execution has two entry points:
- `run` is an explicit resume. It joins an active drain chain or starts one, and performs at least one provider attempt even when no input is eligible. - `run` is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a provider attempt.
- `wake` reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input. - `wake` reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input.
Post-crash activity recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model durable activity identity, provider-dispatch ambiguity, required continuation, queue-opener reservation, retry policy, and visible recovery status together. Post-crash activity recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model durable activity identity, provider-dispatch ambiguity, required continuation, queue-opener reservation, retry policy, and visible recovery status together.
A process-global `SessionRunCoordinator` serializes each local Session drain chain while allowing different Sessions to drain concurrently. It enters the Session's current Location only when a drain starts, so interruption targets process execution ownership rather than Location cache identity. Interruption establishes a local ownership-chain boundary by stopping the current chain while preserving pending/unpromoted durable inbox rows for a later fresh wake and projected history for explicit resume. A Location runner also fences every new provider turn against its captured Location so a moved Session cannot begin another turn through source-Location tools or context. An already-dispatched provider turn may still settle source-Location calls until a future move-control slice interrupts active ownership. Automatic startup discovery, durable multi-node ownership, stale-owner fencing, and retry policy remain future work. A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location.
Inbox promotion coalesces pending steers in durable admission order and opens one queued activity at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. Inbox promotion coalesces pending steers in durable admission order and opens one queued activity at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth.

View file

@ -33,12 +33,7 @@ through legacy `SessionPrompt.loop(...)`:
Prompt admission now uses a durable `session_input` inbox rather than immediate Prompt admission now uses a durable `session_input` inbox rather than immediate
transcript projection. `steer` inputs coalesce into the active activity at the transcript projection. `steer` inputs coalesce into the active activity at the
next safe provider-turn boundary. `queue` inputs form a FIFO of future activities next safe provider-turn boundary. `queue` inputs form a FIFO of future activities
that open one at a time. A process-global `SessionRunCoordinator` coalesces process-local wakeups that open one at a time.
around settlement races. Explicit `run` resumes perform at least one provider
attempt; advisory `wake` notifications call the provider only for eligible inbox
work. Steers coalesce into the active activity at
safe provider boundaries; queued inputs open later activities one at a time in
FIFO order.
Next reviewed slices: Next reviewed slices: