mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 23:03:41 +00:00
refactor(core): simplify session runner loop and pending input scopes (#38602)
This commit is contained in:
parent
35d31d8ec1
commit
0f3c30118c
8 changed files with 286 additions and 245 deletions
|
|
@ -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<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)(
|
||||
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))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<CallOutcome>()
|
||||
|
||||
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<SessionSchema.ID>()
|
||||
const titleStarted = new Set<SessionSchema.ID>()
|
||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
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 = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
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
|
||||
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<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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// 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 })
|
||||
|
|
|
|||
|
|
@ -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<RetryableFailure | SessionRunner.RunError>(),
|
||||
Schedule.passthrough,
|
||||
Schedule.while(({ input }) => input instanceof RetryableFailure),
|
||||
Schedule.setInputType<RetryableFailure>(),
|
||||
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,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(`<subagent id="${childID}" state="completed"`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue