diff --git a/packages/core/src/session/pending.ts b/packages/core/src/session/pending.ts index 70f0b34c5b3..6753903be9b 100644 --- a/packages/core/src/session/pending.ts +++ b/packages/core/src/session/pending.ts @@ -1,6 +1,6 @@ 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 { Compaction, @@ -26,6 +26,13 @@ type DatabaseService = Database.Interface["db"] 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 encodeUser = Schema.encodeSync(UserData) const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData) @@ -355,16 +362,32 @@ export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseServ 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* ( db: DatabaseService, 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 .select({ id: SessionPendingTable.id }) .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) .get() .pipe(Effect.orDie) @@ -393,66 +416,74 @@ const publish = Effect.fn("SessionPending.publish")(function* ( events: EventV2.Interface, sessionID: SessionSchema.ID, rows: ReadonlyArray, +) { + 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)( Effect.gen(function* () { 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 + const steers = 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) + if (steers.length > 0 || scope === "steer") return yield* publish(db, events, sessionID, steers) + + const queued = 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) + if (!queued) return 0 + const promoted = yield* publish(db, events, sessionID, [queued]) + const arrivedSteers = 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 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)) -}) diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index ee33da093f8..65fdb223524 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -20,7 +20,7 @@ export type RunError = /** Runs one local continuation from already-recorded Session history. */ 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 sessionID: SessionSchema.ID readonly force: boolean diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ff00a8337bb..67666da35bd 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,8 +1,7 @@ export * as SessionRunnerLLM from "./llm" import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai" -import { SessionError } from "@opencode-ai/schema/session-error" -import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" +import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { PermissionV2 } from "../../permission" @@ -28,6 +27,14 @@ import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" 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() + const layer = Layer.effect( Service, 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. // 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. - const titleAttempted = new Set() + const titleStarted = new Set() const forkTitle = yield* FiberSet.makeRuntime() const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) 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, ) { 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), ) - 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, - promotion: SessionPending.Delivery | undefined, + promotable: SessionPending.Promotable | undefined, step: number, - recoverOverflow?: typeof compaction.compact, + recoverOverflow: boolean, assistantMessageID?: SessionMessage.ID, ) { const selected = yield* context.select(sessionID) @@ -88,24 +106,20 @@ const layer = Layer.effect( // a blocked first step leaves pending inputs untouched. yield* InstructionState.prepare(db, events, selected.instructions, selected.session.id) let currentStep = step - if (promotion) { - let promoted = 0 - 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 (promotable) { + const promoted = yield* SessionPending.promote(db, events, selected.session.id, promotable) if (promoted > 0) currentStep = 1 } const loaded = yield* context.load(selected) - const session = loaded.session - const agent = loaded.agent + const { session, agent } = loaded const resolved = loaded.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 } if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { 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 }) } const prepared = yield* modelRequests.prepare({ @@ -131,6 +145,40 @@ const layer = Layer.effect( // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) const publish = (event: LLMEvent) => serialized(publisher.publish(event)) + + const stepUsage = (settlement: NonNullable>) => ({ + 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>) => + 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 const providerStream = llm.stream(prepared.request).pipe( Stream.runForEach((event) => @@ -172,39 +220,10 @@ const layer = Layer.effect( Effect.ensuring(serialized(publisher.flush())), ) - const stepUsage = (settlement: NonNullable>) => ({ - 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>) => - 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, - }), - ) - }) - + // Settle: only the stream itself is interruptible (restore); every line after it is + // protected so a started call always reaches one durable outcome. return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - // Gather the evidence: how did the provider stream end? const stream = yield* restore(providerStream).pipe(Effect.exit) const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream)) // Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows @@ -217,10 +236,10 @@ const layer = Layer.effect( recoverOverflow && !publisher.hasRetryEvidence() && 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" ) - 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 // 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) if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause) if (stepFailure) return yield* new StepFailedError({ error: stepFailure }) - return { - _tag: "Completed", - needsContinuation, - step: currentStep, - } as const + return CallOutcome.Completed({ needsContinuation, step: currentStep }) }), ) }, Effect.scoped) + /** Completes one logical model step, transparently retrying or rebuilding after compaction. */ const runStep = Effect.fnUntraced(function* ( sessionID: SessionSchema.ID, - promotion: SessionPending.Delivery | undefined, + promotable: SessionPending.Promotable, step: number, ) { - // Compaction restarts rebuild the request from compacted history without re-promoting. - // Overflow recovery is one-shot: a post-compaction attempt must not recover another - // overflow, so the recovery hook is dropped after it fires. - let recoverOverflow: typeof compaction.compact | undefined = compaction.compact - let currentPromotion = promotion - let currentStep = step - let assistantMessageID: SessionMessage.ID | undefined - while (true) { - const attempt = yield* Effect.suspend(() => - attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID), - ).pipe( - 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 + const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(events, sessionID)) + /** + * Consumes one retry allowance: sleeps the scheduled backoff and reports what the next + * attempt should reuse, or publishes Step.Failed and fails once attempts are exhausted. + * The step loop performs the retry itself on the next iteration. + */ + const waitForRetry = (failure: SessionRunnerRetry.RetryableFailure) => + retry(failure).pipe( + Effect.as(CallOutcome.Retry({ step: failure.step, assistantMessageID: failure.assistantMessageID })), + Pull.catchDone(() => + events .publish(SessionEvent.Step.Failed, { sessionID, - assistantMessageID: error.assistantMessageID, - error: error.error, + assistantMessageID: failure.assistantMessageID, + error: failure.error, }) - .pipe(Effect.andThen(Effect.fail(error.cause))) - }), + .pipe(Effect.andThen(Effect.fail(failure.cause))), + ), ) - if (attempt._tag === "Completed") - return { - needsContinuation: attempt.needsContinuation, - step: attempt.step, - } - if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined - yield* Effect.yieldNow - currentPromotion = undefined - currentStep = attempt.step + let currentPromotable: SessionPending.Promotable | undefined = promotable + let currentStep = step + let assistantMessageID: SessionMessage.ID | undefined + // Overflow recovery is one-shot: a call after recovery must not recover another overflow. + let recoverOverflow = true + while (true) { + const outcome = yield* callModel( + sessionID, + 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* ( sessionID: SessionSchema.ID, ) { @@ -399,67 +414,54 @@ const layer = Layer.effect( }), ).pipe(Effect.exit) if (Exit.isSuccess(compacted)) return - if (Exit.isFailure(compacted)) { - const unsettled = yield* SessionPending.compaction(db, sessionID) - if (unsettled) - yield* events.publish(SessionEvent.Compaction.Failed, { - sessionID, - reason: "manual", - error: Cause.hasInterruptsOnly(compacted.cause) - ? { type: "aborted", message: "Compaction cancelled" } - : { type: "compaction.failed", message: Cause.pretty(compacted.cause) }, - inputID: unsettled.id, - }) - return yield* Effect.failCause(compacted.cause) - } + const unsettled = yield* SessionPending.compaction(db, sessionID) + if (unsettled) + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "manual", + error: Cause.hasInterruptsOnly(compacted.cause) + ? { type: "aborted", message: "Compaction cancelled" } + : { type: "compaction.failed", message: Cause.pretty(compacted.cause) }, + inputID: unsettled.id, + }) + 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: { readonly sessionID: SessionSchema.ID readonly force: boolean }) { + if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return + yield* settleStaleToolCalls(input.sessionID) yield* runPendingCompaction(input.sessionID) - const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer") - const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue") - if (!input.force && !hasSteer && !hasQueue) return - yield* failInterruptedTools(input.sessionID) - 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 - } + if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return + do { + yield* runSteps(input.sessionID) + } while (yield* SessionPending.has(db, input.sessionID, "input")) }) return Service.of({ drain }) diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts index 11626b586d9..94401a35d68 100644 --- a/packages/core/src/session/runner/retry.ts +++ b/packages/core/src/session/runner/retry.ts @@ -7,7 +7,6 @@ import { EventV2 } from "../../event" import { SessionEvent } from "../event" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" -import type { SessionRunner } from "./index" export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{ readonly cause: LLMError @@ -45,22 +44,18 @@ const retryAfter = (failure: RetryableFailure) => { export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) => Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe( - Schedule.setInputType(), - Schedule.passthrough, - Schedule.while(({ input }) => input instanceof RetryableFailure), + Schedule.setInputType(), 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))) }), Schedule.tap((metadata) => - metadata.input instanceof RetryableFailure - ? events.publish(SessionEvent.RetryScheduled, { - sessionID, - assistantMessageID: metadata.input.assistantMessageID, - attempt: metadata.attempt + 1, - at: metadata.now + Duration.toMillis(metadata.duration), - error: metadata.input.error, - }) - : Effect.void, + events.publish(SessionEvent.RetryScheduled, { + sessionID, + assistantMessageID: metadata.input.assistantMessageID, + attempt: metadata.attempt + 1, + at: metadata.now + Duration.toMillis(metadata.duration), + error: metadata.input.error, + }), ), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 80136f02f86..422d5892300 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -195,9 +195,9 @@ describe("SessionV2.create", () => { text: "First", 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* SessionPending.promoteSteers(db, events, parent.id) + yield* SessionPending.promote(db, events, parent.id, "steer") const forked = yield* session.fork({ sessionID: parent.id }) const parentContext = yield* session.context(parent.id) @@ -232,13 +232,13 @@ describe("SessionV2.create", () => { text: "Parent changed", resume: false, }) - yield* SessionPending.promoteSteers(db, events, parent.id) + yield* SessionPending.promote(db, events, parent.id, "steer") yield* session.prompt({ sessionID: forked.id, text: "Child continues", 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(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) @@ -263,13 +263,13 @@ describe("SessionV2.create", () => { text: "First", resume: false, }) - yield* SessionPending.promoteSteers(db, events, parent.id) + yield* SessionPending.promote(db, events, parent.id, "steer") const second = yield* session.prompt({ sessionID: parent.id, text: "Second", resume: false, }) - yield* SessionPending.promoteSteers(db, events, parent.id) + yield* SessionPending.promote(db, events, parent.id, "steer") const assistantMessageID = SessionMessage.ID.create() const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) yield* events.publish(SessionEvent.Step.Started, { @@ -414,7 +414,7 @@ describe("SessionV2.create", () => { text: "Hello", resume: false, }) - yield* SessionPending.promoteSteers(db, events, created.id) + yield* SessionPending.promote(db, events, created.id, "steer") expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)), @@ -440,7 +440,7 @@ describe("SessionV2.create", () => { text: "Replay lifecycle", resume: false, }) - yield* SessionPending.promoteSteers(sourceDb, sourceEvents, created.id) + yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer") const serialized = (yield* sourceDb .select() .from(EventTable) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 94115435124..8cab48f14a1 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -216,7 +216,7 @@ describe("SessionV2.prompt", () => { text: "boundary", resume: false, }) - yield* SessionPending.promoteSteers(db, events, sessionID) + yield* SessionPending.promote(db, events, sessionID, "steer") const stale = SessionMessage.ID.make("msg_stale_assistant") yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie) yield* events.publish(SessionEvent.RevertEvent.Staged, { @@ -248,7 +248,7 @@ describe("SessionV2.prompt", () => { text: "boundary", resume: false, }) - yield* SessionPending.promoteSteers(db, events, sessionID) + yield* SessionPending.promote(db, events, sessionID, "steer") yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, 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: "Second", resume: false }) - yield* SessionPending.promoteSteers(db, events, sessionID) + yield* SessionPending.promote(db, events, sessionID, "steer") const streamed = Array.from(yield* Fiber.join(fiber)) expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([ @@ -625,7 +625,7 @@ describe("SessionV2.prompt", () => { }) 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" }, ) @@ -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([ { @@ -880,7 +880,7 @@ describe("SessionV2.prompt", () => { const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], { concurrency: "unbounded", }) - yield* SessionPending.promoteSteers(database.db, events, sessionID) + yield* SessionPending.promote(database.db, events, sessionID, "steer") const promotedRetry = yield* session.synthetic(input) 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* () { yield* setup const session = yield* SessionV2.Service @@ -907,9 +907,15 @@ describe("SessionV2.prompt", () => { }) 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* 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([ { id: input.id, type: "synthetic", text: "Queued completion" }, ]) @@ -935,7 +941,7 @@ describe("SessionV2.prompt", () => { resume: false, }) - yield* SessionPending.promoteSteers(db, events, sessionID) + yield* SessionPending.promote(db, events, sessionID, "steer") expect( (yield* session.messages({ sessionID, order: "asc" })).map((message) => @@ -978,10 +984,14 @@ describe("SessionV2.pending", () => { { 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" }]) - yield* SessionPending.promoteNextQueued(db, events, sessionID) + expect( + yield* SessionPending.promote(db, events, sessionID, "input"), + ).toBe(1) expect(yield* session.pending(sessionID)).toEqual([]) }), ) @@ -993,9 +1003,12 @@ describe("SessionV2.pending", () => { const { db } = yield* Database.Service 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" }]) yield* SessionPending.settleCompaction(db, { sessionID }) + expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false) expect(yield* session.pending(sessionID)).toEqual([]) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index a96e08eb545..a6f6d0c43e9 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3099,7 +3099,7 @@ describe("SessionRunnerLLM", () => { streamFailure = undefined streamGate = undefined streamStarted = undefined - yield* Effect.yieldNow + yield* session.wait(sessionID) expect(requests).toHaveLength(2) expect(userTexts(requests[1]!)).toEqual(["Start working", "Recover with this"]) @@ -3111,7 +3111,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const events = yield* EventV2.Service 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() yield* events.publish(SessionEvent.Step.Started, { sessionID, @@ -3168,7 +3168,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const events = yield* EventV2.Service 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() yield* events.publish(SessionEvent.Step.Started, { sessionID, @@ -3219,7 +3219,7 @@ describe("SessionRunnerLLM", () => { const session = yield* setup const events = yield* EventV2.Service 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() yield* events.publish(SessionEvent.Step.Started, { 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* () { const session = yield* setup const agents = yield* AgentV2.Service diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 8ec8ecec162..03705eee217 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -406,7 +406,7 @@ describe("SubagentTool", () => { }, }) 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") expect(synthetic).toHaveLength(1) expect(synthetic[0]?.text).toContain(`