refactor(core): simplify session runner loop and pending input scopes (#38602)

This commit is contained in:
Kit Langton 2026-07-24 10:27:23 -04:00 committed by GitHub
commit 0f3c30118c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 283 additions and 242 deletions

View file

@ -1,6 +1,6 @@
export * as SessionPending from "./pending" export * as SessionPending from "./pending"
import { and, asc, eq } from "drizzle-orm" import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { import {
Compaction, Compaction,
@ -26,6 +26,13 @@ type DatabaseService = Database.Interface["db"]
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData } export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserData) const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData) const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData) const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
@ -355,16 +362,32 @@ export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseServ
return rows.map(fromRow) return rows.map(fromRow)
}) })
/**
* Which pending rows count: "any" counts every row including compaction, while
* delivery scopes are blocked behind a pending compaction barrier. "input" means
* any model-facing input, steered or queued.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionPending.has")(function* ( export const has = Effect.fn("SessionPending.has")(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
delivery: Delivery, scope: Scope,
) { ) {
if (yield* compaction(db, sessionID)) return false if (scope !== "any" && (yield* compaction(db, sessionID))) return false
const row = yield* db const row = yield* db
.select({ id: SessionPendingTable.id }) .select({ id: SessionPendingTable.id })
.from(SessionPendingTable) .from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, delivery))) .where(
and(
eq(SessionPendingTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionPendingTable.delivery, "steer"), eq(SessionPendingTable.delivery, "queue"))
: eq(SessionPendingTable.delivery, scope),
),
)
.limit(1) .limit(1)
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@ -393,66 +416,74 @@ const publish = Effect.fn("SessionPending.publish")(function* (
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>, rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return events
.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromHistory(db, sessionID, entry.id).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionPending.promote")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) { ) {
return yield* inboxLocks.withLock(sessionID)( return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () { Effect.gen(function* () {
if (yield* compaction(db, sessionID)) return 0 if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach( const steers = yield* db
rows, .select()
(row) => { .from(SessionPendingTable)
const entry = fromRow(row) .where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id })) .orderBy(asc(SessionPendingTable.admitted_seq))
return events .all()
.publish(SessionEvent.InputPromoted, { .pipe(Effect.orDie)
sessionID, if (steers.length > 0 || scope === "steer") return yield* publish(db, events, sessionID, steers)
inputID: entry.id,
}) const queued = yield* db
.pipe( .select()
Effect.catchDefect((defect) => .from(SessionPendingTable)
defect instanceof LifecycleConflict .where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
? promotedFromHistory(db, sessionID, entry.id).pipe( .orderBy(asc(SessionPendingTable.admitted_seq))
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))), .limit(1)
) .get()
: Effect.die(defect), .pipe(Effect.orDie)
), if (!queued) return 0
) const promoted = yield* publish(db, events, sessionID, [queued])
}, const arrivedSteers = yield* db
{ discard: true }, .select()
) .from(SessionPendingTable)
return rows.length .where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return promoted + (yield* publish(db, events, sessionID, arrivedSteers))
}), }),
) )
}) })
export const promoteSteers = Effect.fn("SessionPending.promoteSteers")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* compaction(db, sessionID)) return 0
const rows = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return yield* publish(db, events, sessionID, rows)
})
export const promoteNextQueued = Effect.fn("SessionPending.promoteNextQueued")(function* (
db: DatabaseService,
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
if (yield* compaction(db, sessionID)) return false
const row = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
})

View file

@ -20,7 +20,7 @@ export type RunError =
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
export interface Interface { export interface Interface {
/** Drains eligible durable work. Explicit runs perform one physical attempt even when no work is eligible. */ /** Drains eligible durable work. Explicit runs make one model call even when no work is eligible. */
readonly drain: (input: { readonly drain: (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean

View file

@ -1,8 +1,7 @@
export * as SessionRunnerLLM from "./llm" export * as SessionRunnerLLM from "./llm"
import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai" import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai"
import { SessionError } from "@opencode-ai/schema/session-error" import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Database } from "../../database/database" import { Database } from "../../database/database"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { PermissionV2 } from "../../permission" import { PermissionV2 } from "../../permission"
@ -28,6 +27,14 @@ import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry" import { SessionRunnerRetry } from "./retry"
import { SessionUsage } from "../usage" import { SessionUsage } from "../usage"
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean; readonly step: number }
Retry: { readonly step: number; readonly assistantMessageID: SessionMessage.ID }
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
}>
const CallOutcome = Data.taggedEnum<CallOutcome>()
const layer = Layer.effect( const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -43,14 +50,21 @@ const layer = Layer.effect(
// Title generation is a side effect of the first step; it must not delay step continuation. // Title generation is a side effect of the first step; it must not delay step continuation.
// Tracked per process so repeated wakes before the second user message arrives don't // Tracked per process so repeated wakes before the second user message arrives don't
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
const titleAttempted = new Set<SessionSchema.ID>() const titleStarted = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>() const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return session return session
}) })
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* ( /** Fires title generation once per process after the first step makes a user message visible. */
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
if (titleStarted.has(sessionID)) return
titleStarted.add(sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
})
/** Closes stale tool calls left active by an earlier interrupted drain. */
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) { ) {
for (const message of yield* store.context(sessionID)) { for (const message of yield* store.context(sessionID)) {
@ -76,11 +90,15 @@ const layer = Layer.effect(
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError), (reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionTool.CancelledError),
) )
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* ( /**
* Prepares and runs at most one model call, executes its local tools, and durably
* settles the step. Compaction may instead request that the logical step restart.
*/
const callModel = Effect.fn("SessionRunner.callModel")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotion: SessionPending.Delivery | undefined, promotable: SessionPending.Promotable | undefined,
step: number, step: number,
recoverOverflow?: typeof compaction.compact, recoverOverflow: boolean,
assistantMessageID?: SessionMessage.ID, assistantMessageID?: SessionMessage.ID,
) { ) {
const selected = yield* context.select(sessionID) const selected = yield* context.select(sessionID)
@ -88,24 +106,20 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched. // a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id) yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id)
let currentStep = step let currentStep = step
if (promotion) { if (promotable) {
let promoted = 0 const promoted = yield* SessionPending.promote(db, events, selected.session.id, promotable)
if (promotion === "steer") promoted = yield* SessionPending.promoteSteers(db, events, selected.session.id)
if (promotion === "queue") {
promoted += Number(yield* SessionPending.promoteNextQueued(db, events, selected.session.id))
promoted += yield* SessionPending.promoteSteers(db, events, selected.session.id)
}
if (promoted > 0) currentStep = 1 if (promoted > 0) currentStep = 1
} }
const loaded = yield* context.load(selected) const loaded = yield* context.load(selected)
const session = loaded.session const { session, agent } = loaded
const agent = loaded.agent
const resolved = loaded.model const resolved = loaded.model
const model = resolved.model const model = resolved.model
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost } const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput) const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const if (compacted.status === "completed") return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
return yield* new StepFailedError({ error: compacted.error }) return yield* new StepFailedError({ error: compacted.error })
} }
const prepared = yield* modelRequests.prepare({ const prepared = yield* modelRequests.prepare({
@ -131,6 +145,40 @@ const layer = Layer.effect(
// mid-event. // mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect) const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent) => serialized(publisher.publish(event)) const publish = (event: LLMEvent) => serialized(publisher.publish(event))
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
return { snapshot, files }
})
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
Effect.gen(function* () {
const end = yield* captureStepEnd()
yield* serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
...stepUsage(settlement),
...end,
}),
)
})
// The stream is defined here but runs inside the settlement mask below: publish each
// event durably, fork one fiber per local tool call, and hold back a virgin
// context-overflow provider error so settlement may recover it via compaction.
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(prepared.request).pipe( const providerStream = llm.stream(prepared.request).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
@ -172,39 +220,10 @@ const layer = Layer.effect(
Effect.ensuring(serialized(publisher.flush())), Effect.ensuring(serialized(publisher.flush())),
) )
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({ // Settle: only the stream itself is interruptible (restore); every line after it is
cost: SessionUsage.calculateCost(resolved.cost, settlement.tokens), // protected so a started call always reaches one durable outcome.
tokens: settlement.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
const snapshot = yield* snapshots.capture()
const files =
startSnapshot && snapshot
? yield* snapshots
.files({ from: startSnapshot, to: snapshot })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
return { snapshot, files }
})
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
Effect.gen(function* () {
const end = yield* captureStepEnd()
yield* serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
...stepUsage(settlement),
...end,
}),
)
})
return yield* Effect.uninterruptibleMask((restore) => return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () { Effect.gen(function* () {
// Gather the evidence: how did the provider stream end?
const stream = yield* restore(providerStream).pipe(Effect.exit) const stream = yield* restore(providerStream).pipe(Effect.exit)
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream)) const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows // Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
@ -217,10 +236,10 @@ const layer = Layer.effect(
recoverOverflow && recoverOverflow &&
!publisher.hasRetryEvidence() && !publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) && isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost }))) (yield* restore(compaction.compact({ session, messages: loaded.messages, model, cost: resolved.cost })))
.status === "completed" .status === "completed"
) )
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const return CallOutcome.Restart({ step: currentStep, recoveredOverflow: true })
// An unrecovered held-back overflow becomes the step's durable provider error. A // An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure records the assistant failure unless a provider error was // thrown LLM failure records the assistant failure unless a provider error was
@ -324,63 +343,59 @@ const layer = Layer.effect(
return yield* Effect.failCause(settledFailure) return yield* Effect.failCause(settledFailure)
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause) if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure }) if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return { return CallOutcome.Completed({ needsContinuation, step: currentStep })
_tag: "Completed",
needsContinuation,
step: currentStep,
} as const
}), }),
) )
}, Effect.scoped) }, Effect.scoped)
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
const runStep = Effect.fnUntraced(function* ( const runStep = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotion: SessionPending.Delivery | undefined, promotable: SessionPending.Promotable,
step: number, step: number,
) { ) {
// Compaction restarts rebuild the request from compacted history without re-promoting. const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(events, sessionID))
// Overflow recovery is one-shot: a post-compaction attempt must not recover another /**
// overflow, so the recovery hook is dropped after it fires. * Consumes one retry allowance: sleeps the scheduled backoff and reports what the next
let recoverOverflow: typeof compaction.compact | undefined = compaction.compact * attempt should reuse, or publishes Step.Failed and fails once attempts are exhausted.
let currentPromotion = promotion * The step loop performs the retry itself on the next iteration.
let currentStep = step */
let assistantMessageID: SessionMessage.ID | undefined const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) =>
while (true) { retry(failure).pipe(
const attempt = yield* Effect.suspend(() => Effect.as(CallOutcome.Retry({ step: failure.step, assistantMessageID: failure.assistantMessageID })),
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID), Pull.catchDone(() =>
).pipe( events
Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure
? Effect.sync(() => {
currentStep = error.step
assistantMessageID = error.assistantMessageID
currentPromotion = undefined
})
: Effect.void,
),
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
return events
.publish(SessionEvent.Step.Failed, { .publish(SessionEvent.Step.Failed, {
sessionID, sessionID,
assistantMessageID: error.assistantMessageID, assistantMessageID: failure.assistantMessageID,
error: error.error, error: failure.error,
}) })
.pipe(Effect.andThen(Effect.fail(error.cause))) .pipe(Effect.andThen(Effect.fail(failure.cause))),
}), ),
) )
if (attempt._tag === "Completed") let currentPromotable: SessionPending.Promotable | undefined = promotable
return { let currentStep = step
needsContinuation: attempt.needsContinuation, let assistantMessageID: SessionMessage.ID | undefined
step: attempt.step, // Overflow recovery is one-shot: a call after recovery must not recover another overflow.
} let recoverOverflow = true
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined while (true) {
yield* Effect.yieldNow const outcome = yield* callModel(
currentPromotion = undefined sessionID,
currentStep = attempt.step currentPromotable,
currentStep,
recoverOverflow,
assistantMessageID,
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
if (outcome._tag === "Retry") assistantMessageID = outcome.assistantMessageID
if (outcome._tag === "Restart" && outcome.recoveredOverflow) recoverOverflow = false
// Neither a retry nor a compaction restart re-promotes input.
currentPromotable = undefined
currentStep = outcome.step
} }
}) })
/** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* ( const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) { ) {
@ -399,67 +414,54 @@ const layer = Layer.effect(
}), }),
).pipe(Effect.exit) ).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return if (Exit.isSuccess(compacted)) return
if (Exit.isFailure(compacted)) { const unsettled = yield* SessionPending.compaction(db, sessionID)
const unsettled = yield* SessionPending.compaction(db, sessionID) if (unsettled)
if (unsettled) yield* events.publish(SessionEvent.Compaction.Failed, {
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID,
sessionID, reason: "manual",
reason: "manual", error: Cause.hasInterruptsOnly(compacted.cause)
error: Cause.hasInterruptsOnly(compacted.cause) ? { type: "aborted", message: "Compaction cancelled" }
? { type: "aborted", message: "Compaction cancelled" } : { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) }, inputID: unsettled.id,
inputID: unsettled.id, })
}) return yield* Effect.failCause(compacted.cause)
return yield* Effect.failCause(compacted.cause)
}
}), }),
) )
}) })
// Execution lifecycle is published per busy period by SessionExecution, not per drain here. /**
* Runs logical steps until no tool result or newly admitted steer requires another
* model call. Queued inputs remain pending until the current model work reaches idle.
*/
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionPending.Promotable = "input"
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
yield* startTitleOnce(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
step = result.step + 1
}
})
/**
* Drains eligible manual compaction and user input until the Session becomes idle.
* Execution lifecycle is published per busy period by SessionExecution, not here.
*/
const drain = Effect.fn("SessionRunner.drain")(function* (input: { const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
}) { }) {
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
yield* settleStaleToolCalls(input.sessionID)
yield* runPendingCompaction(input.sessionID) yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer") if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue") do {
if (!input.force && !hasSteer && !hasQueue) return yield* runSteps(input.sessionID)
yield* failInterruptedTools(input.sessionID) } while (yield* SessionPending.has(db, input.sessionID, "input"))
let promotion: SessionPending.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
let shouldRun = input.force || hasSteer || hasQueue
while (shouldRun) {
let needsContinuation = true
let step = 1
// Repeat steps while continuation is needed. A step needs continuation only
// when it recorded local tool calls whose results the model has not yet seen;
// a provider error suppresses it. Pending steers also continue the loop so
// interjections are answered before the session goes idle.
while (needsContinuation) {
const result = yield* runStep(input.sessionID, promotion, step)
// Steer/queue promotion inside runStep has already made the pending input a visible
// user message by this point, so the first-user-message check below is reliable.
if (!titleAttempted.has(input.sessionID)) {
titleAttempted.add(input.sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
}
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
promotion = "steer"
needsContinuation = yield* SessionPending.has(db, input.sessionID, "steer")
}
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
}
}) })
return Service.of({ drain }) return Service.of({ drain })

View file

@ -7,7 +7,6 @@ import { EventV2 } from "../../event"
import { SessionEvent } from "../event" import { SessionEvent } from "../event"
import { SessionMessage } from "../message" import { SessionMessage } from "../message"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import type { SessionRunner } from "./index"
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{ export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: LLMError readonly cause: LLMError
@ -45,22 +44,18 @@ const retryAfter = (failure: RetryableFailure) => {
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) => export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe( Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(), Schedule.setInputType<RetryableFailure>(),
Schedule.passthrough,
Schedule.while(({ input }) => input instanceof RetryableFailure),
Schedule.modifyDelay(({ input: failure, duration: delay }) => { Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined const minimum = retryAfter(failure)
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))) return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}), }),
Schedule.tap((metadata) => Schedule.tap((metadata) =>
metadata.input instanceof RetryableFailure events.publish(SessionEvent.RetryScheduled, {
? events.publish(SessionEvent.RetryScheduled, { sessionID,
sessionID, assistantMessageID: metadata.input.assistantMessageID,
assistantMessageID: metadata.input.assistantMessageID, attempt: metadata.attempt + 1,
attempt: metadata.attempt + 1, at: metadata.now + Duration.toMillis(metadata.duration),
at: metadata.now + Duration.toMillis(metadata.duration), error: metadata.input.error,
error: metadata.input.error, }),
})
: Effect.void,
), ),
) )

View file

@ -195,9 +195,9 @@ describe("SessionV2.create", () => {
text: "First", text: "First",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, parent.id) yield* SessionPending.promote(db, events, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false }) yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionPending.promoteSteers(db, events, parent.id) yield* SessionPending.promote(db, events, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id }) const forked = yield* session.fork({ sessionID: parent.id })
const parentContext = yield* session.context(parent.id) const parentContext = yield* session.context(parent.id)
@ -232,13 +232,13 @@ describe("SessionV2.create", () => {
text: "Parent changed", text: "Parent changed",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, parent.id) yield* SessionPending.promote(db, events, parent.id, "steer")
yield* session.prompt({ yield* session.prompt({
sessionID: forked.id, sessionID: forked.id,
text: "Child continues", text: "Child continues",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, forked.id) yield* SessionPending.promote(db, events, forked.id, "steer")
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@ -263,13 +263,13 @@ describe("SessionV2.create", () => {
text: "First", text: "First",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, parent.id) yield* SessionPending.promote(db, events, parent.id, "steer")
const second = yield* session.prompt({ const second = yield* session.prompt({
sessionID: parent.id, sessionID: parent.id,
text: "Second", text: "Second",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, parent.id) yield* SessionPending.promote(db, events, parent.id, "steer")
const assistantMessageID = SessionMessage.ID.create() const assistantMessageID = SessionMessage.ID.create()
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") })
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
@ -414,7 +414,7 @@ describe("SessionV2.create", () => {
text: "Hello", text: "Hello",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, created.id) yield* SessionPending.promote(db, events, created.id, "steer")
expect( expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
@ -440,7 +440,7 @@ describe("SessionV2.create", () => {
text: "Replay lifecycle", text: "Replay lifecycle",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(sourceDb, sourceEvents, created.id) yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
const serialized = (yield* sourceDb const serialized = (yield* sourceDb
.select() .select()
.from(EventTable) .from(EventTable)

View file

@ -216,7 +216,7 @@ describe("SessionV2.prompt", () => {
text: "boundary", text: "boundary",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, sessionID) yield* SessionPending.promote(db, events, sessionID, "steer")
const stale = SessionMessage.ID.make("msg_stale_assistant") const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, { yield* events.publish(SessionEvent.RevertEvent.Staged, {
@ -248,7 +248,7 @@ describe("SessionV2.prompt", () => {
text: "boundary", text: "boundary",
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, sessionID) yield* SessionPending.promote(db, events, sessionID, "steer")
yield* events.publish(SessionEvent.RevertEvent.Staged, { yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID, sessionID,
revert: { messageID: boundary.id, files: [] }, revert: { messageID: boundary.id, files: [] },
@ -448,7 +448,7 @@ describe("SessionV2.prompt", () => {
yield* session.prompt({ sessionID, text: "First", resume: false }) yield* session.prompt({ sessionID, text: "First", resume: false })
yield* session.prompt({ sessionID, text: "Second", resume: false }) yield* session.prompt({ sessionID, text: "Second", resume: false })
yield* SessionPending.promoteSteers(db, events, sessionID) yield* SessionPending.promote(db, events, sessionID, "steer")
const streamed = Array.from(yield* Fiber.join(fiber)) const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([ expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
@ -625,7 +625,7 @@ describe("SessionV2.prompt", () => {
}) })
yield* Effect.all( yield* Effect.all(
[SessionPending.promoteSteers(db, events, sessionID), SessionPending.promoteSteers(db, events, sessionID)], [SessionPending.promote(db, events, sessionID, "steer"), SessionPending.promote(db, events, sessionID, "steer")],
{ concurrency: "unbounded" }, { concurrency: "unbounded" },
) )
@ -855,7 +855,7 @@ describe("SessionV2.prompt", () => {
}, },
}) })
yield* SessionPending.promoteSteers(db, events, sessionID) yield* SessionPending.promote(db, events, sessionID, "steer")
expect(yield* session.messages({ sessionID })).toMatchObject([ expect(yield* session.messages({ sessionID })).toMatchObject([
{ {
@ -880,7 +880,7 @@ describe("SessionV2.prompt", () => {
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], { const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
concurrency: "unbounded", concurrency: "unbounded",
}) })
yield* SessionPending.promoteSteers(database.db, events, sessionID) yield* SessionPending.promote(database.db, events, sessionID, "steer")
const promotedRetry = yield* session.synthetic(input) const promotedRetry = yield* session.synthetic(input)
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip) const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
@ -892,7 +892,7 @@ describe("SessionV2.prompt", () => {
}), }),
) )
it.effect("keeps synthetic queue input pending until the queue boundary", () => it.effect("keeps queued input pending until the idle boundary", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
@ -907,9 +907,15 @@ describe("SessionV2.prompt", () => {
}) })
expect(input.delivery).toBe("queue") expect(input.delivery).toBe("queue")
expect(yield* SessionPending.promoteSteers(db, events, sessionID)).toBe(0) expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
expect(
yield* SessionPending.promote(db, events, sessionID, "steer"),
).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* SessionPending.promoteNextQueued(db, events, sessionID)).toBe(true) expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
).toBe(1)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([ expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: input.id, type: "synthetic", text: "Queued completion" }, { id: input.id, type: "synthetic", text: "Queued completion" },
]) ])
@ -935,7 +941,7 @@ describe("SessionV2.prompt", () => {
resume: false, resume: false,
}) })
yield* SessionPending.promoteSteers(db, events, sessionID) yield* SessionPending.promote(db, events, sessionID, "steer")
expect( expect(
(yield* session.messages({ sessionID, order: "asc" })).map((message) => (yield* session.messages({ sessionID, order: "asc" })).map((message) =>
@ -978,10 +984,14 @@ describe("SessionV2.pending", () => {
{ id: second.id, type: "user", delivery: "steer" }, { id: second.id, type: "user", delivery: "steer" },
]) ])
yield* SessionPending.promoteSteers(db, events, sessionID) expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
).toBe(2)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }]) expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
yield* SessionPending.promoteNextQueued(db, events, sessionID) expect(
yield* SessionPending.promote(db, events, sessionID, "input"),
).toBe(1)
expect(yield* session.pending(sessionID)).toEqual([]) expect(yield* session.pending(sessionID)).toEqual([])
}), }),
) )
@ -993,9 +1003,12 @@ describe("SessionV2.pending", () => {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const barrier = yield* session.compact({ sessionID }) const barrier = yield* session.compact({ sessionID })
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }]) expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
yield* SessionPending.settleCompaction(db, { sessionID }) yield* SessionPending.settleCompaction(db, { sessionID })
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
expect(yield* session.pending(sessionID)).toEqual([]) expect(yield* session.pending(sessionID)).toEqual([])
}), }),
) )

View file

@ -3099,7 +3099,7 @@ describe("SessionRunnerLLM", () => {
streamFailure = undefined streamFailure = undefined
streamGate = undefined streamGate = undefined
streamStarted = undefined streamStarted = undefined
yield* Effect.yieldNow yield* session.wait(sessionID)
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"]) expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"])
@ -3111,7 +3111,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup const session = yield* setup
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* admit(session, "Recover interrupted tool") yield* admit(session, "Recover interrupted tool")
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID) yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create() const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
@ -3168,7 +3168,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup const session = yield* setup
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* admit(session, "Recover interrupted hosted tool") yield* admit(session, "Recover interrupted hosted tool")
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID) yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create() const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
@ -3219,7 +3219,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup const session = yield* setup
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* admit(session, "Recover interrupted tool input") yield* admit(session, "Recover interrupted tool input")
yield* SessionPending.promoteSteers((yield* Database.Service).db, events, sessionID) yield* SessionPending.promote((yield* Database.Service).db, events, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create() const assistantMessageID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
@ -4220,7 +4220,7 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("retries a physical attempt without consuming the logical agent step", () => it.effect("retries a model call without consuming the logical agent step", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
const agents = yield* AgentV2.Service const agents = yield* AgentV2.Service

View file

@ -406,7 +406,7 @@ describe("SubagentTool", () => {
}, },
}) })
const database = yield* Database.Service const database = yield* Database.Service
yield* SessionPending.promoteSteers(database.db, events, parent.id) yield* SessionPending.promote(database.db, events, parent.id, "steer")
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic") const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1) expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`) expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)