fix(core): resume background completions
This commit is contained in:
parent
ed4f833813
commit
e4b4b913fc
5 changed files with 79 additions and 10 deletions
|
|
@ -689,9 +689,7 @@ const layer = Layer.effect(
|
||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
})
|
})
|
||||||
if (input.resume === false) return
|
if (input.resume === false) return
|
||||||
yield* execution
|
yield* execution.wake(input.sessionID, { force: true })
|
||||||
.resume(input.sessionID)
|
|
||||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
|
||||||
}),
|
}),
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Context, Effect, Layer } from "effect"
|
||||||
import { LayerNode } from "../effect/layer-node"
|
import { LayerNode } from "../effect/layer-node"
|
||||||
import { Node } from "../effect/app-node"
|
import { Node } from "../effect/app-node"
|
||||||
import { SessionRunner } from "./runner/index"
|
import { SessionRunner } from "./runner/index"
|
||||||
|
import type { SessionRunCoordinator } from "./run-coordinator"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|
@ -11,8 +12,8 @@ export interface Interface {
|
||||||
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
|
||||||
/** Starts execution while idle or joins the active execution. */
|
/** Starts execution while idle or joins the active execution. */
|
||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
/** Registers newly recorded work. Repeated wakeups may coalesce. Force runs even without pending input. */
|
||||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly wake: (sessionID: SessionSchema.ID, options?: SessionRunCoordinator.WakeOptions) => Effect.Effect<void>
|
||||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,15 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||||
readonly wake: (key: Key) => Effect.Effect<void>
|
readonly wake: (key: Key, options?: WakeOptions) => Effect.Effect<void>
|
||||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WakeOptions = { readonly force?: boolean }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||||
|
|
@ -27,6 +29,7 @@ type Execution<E, Reason> = {
|
||||||
readonly done: Deferred.Deferred<void, E>
|
readonly done: Deferred.Deferred<void, E>
|
||||||
owner?: Fiber.Fiber<void>
|
owner?: Fiber.Fiber<void>
|
||||||
pendingWake: boolean
|
pendingWake: boolean
|
||||||
|
pendingForce: boolean
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
settling: boolean
|
settling: boolean
|
||||||
interruptionReason?: Reason
|
interruptionReason?: Reason
|
||||||
|
|
@ -63,8 +66,10 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||||
execution.pendingWake = false
|
execution.pendingWake = false
|
||||||
|
const force = execution.pendingForce
|
||||||
|
execution.pendingForce = false
|
||||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, force)))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -73,6 +78,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
const execution: Execution<E, Reason> = {
|
const execution: Execution<E, Reason> = {
|
||||||
done: Deferred.makeUnsafe<void, E>(),
|
done: Deferred.makeUnsafe<void, E>(),
|
||||||
pendingWake: false,
|
pendingWake: false,
|
||||||
|
pendingForce: false,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
settling: false,
|
settling: false,
|
||||||
}
|
}
|
||||||
|
|
@ -100,7 +106,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||||
if (execution.pendingWake) start(key, false)
|
if (execution.pendingWake) start(key, execution.pendingForce)
|
||||||
else executions.delete(key)
|
else executions.delete(key)
|
||||||
Deferred.doneUnsafe(execution.done, exit)
|
Deferred.doneUnsafe(execution.done, exit)
|
||||||
}
|
}
|
||||||
|
|
@ -116,14 +122,16 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
return restore(Deferred.await(start(key, true).done))
|
return restore(Deferred.await(start(key, true).done))
|
||||||
})
|
})
|
||||||
|
|
||||||
const wake = (key: Key) =>
|
const wake = (key: Key, options?: { readonly force?: boolean }) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
const force = options?.force === true
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution !== undefined) {
|
if (execution !== undefined) {
|
||||||
execution.pendingWake = true
|
execution.pendingWake = true
|
||||||
|
execution.pendingForce ||= force
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
start(key, false)
|
start(key, force)
|
||||||
})
|
})
|
||||||
|
|
||||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||||
|
|
@ -132,6 +140,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||||
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
|
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
|
||||||
execution.stopping = true
|
execution.stopping = true
|
||||||
execution.pendingWake = false
|
execution.pendingWake = false
|
||||||
|
execution.pendingForce = false
|
||||||
execution.interruptionReason = reason
|
execution.interruptionReason = reason
|
||||||
return Fiber.interrupt(execution.owner)
|
return Fiber.interrupt(execution.owner)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,36 @@ describe("SessionRunCoordinator", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves a forced wake received during active execution", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const firstGate = yield* Deferred.make<void>()
|
||||||
|
const secondStarted = yield* Deferred.make<void>()
|
||||||
|
const forces: boolean[] = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (_key, force) =>
|
||||||
|
Effect.sync(() => forces.push(force)).pipe(
|
||||||
|
Effect.flatMap(() =>
|
||||||
|
forces.length === 1
|
||||||
|
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(firstGate)))
|
||||||
|
: Deferred.succeed(secondStarted, undefined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* coordinator.wake("session")
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
yield* coordinator.wake("session", { force: true })
|
||||||
|
yield* Deferred.succeed(firstGate, undefined)
|
||||||
|
yield* Deferred.await(secondStarted)
|
||||||
|
yield* coordinator.awaitIdle("session")
|
||||||
|
|
||||||
|
expect(forces).toEqual([false, true])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("runs again when woken during the follow-up", () =>
|
it.effect("runs again when woken during the follow-up", () =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ const requests: LLMRequest[] = []
|
||||||
let response: LLMEvent[] = []
|
let response: LLMEvent[] = []
|
||||||
let responses: LLMEvent[][] | undefined
|
let responses: LLMEvent[][] | undefined
|
||||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||||
|
let responseStreams: Stream.Stream<LLMEvent, LLMError>[] | undefined
|
||||||
let streamGate: Deferred.Deferred<void> | undefined
|
let streamGate: Deferred.Deferred<void> | undefined
|
||||||
let streamStarted: Deferred.Deferred<void> | undefined
|
let streamStarted: Deferred.Deferred<void> | undefined
|
||||||
let streamFailure: LLMError | undefined
|
let streamFailure: LLMError | undefined
|
||||||
|
|
@ -85,6 +86,7 @@ const client = Layer.succeed(
|
||||||
prepare: () => Effect.die("unused"),
|
prepare: () => Effect.die("unused"),
|
||||||
stream: ((request: LLMRequest) => {
|
stream: ((request: LLMRequest) => {
|
||||||
requests.push(request)
|
requests.push(request)
|
||||||
|
if (responseStreams) return responseStreams.shift() ?? Stream.empty
|
||||||
if (responseStream) {
|
if (responseStream) {
|
||||||
const stream = responseStream
|
const stream = responseStream
|
||||||
responseStream = undefined
|
responseStream = undefined
|
||||||
|
|
@ -416,6 +418,7 @@ const setup = Effect.gen(function* () {
|
||||||
responses = undefined
|
responses = undefined
|
||||||
streamFailure = undefined
|
streamFailure = undefined
|
||||||
responseStream = undefined
|
responseStream = undefined
|
||||||
|
responseStreams = undefined
|
||||||
streamGate = undefined
|
streamGate = undefined
|
||||||
streamStarted = undefined
|
streamStarted = undefined
|
||||||
toolExecutionGate = undefined
|
toolExecutionGate = undefined
|
||||||
|
|
@ -768,6 +771,34 @@ describe("SessionRunnerLLM", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("runs a follow-up when synthetic context arrives during an active continuation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
const secondStarted = yield* Deferred.make<void>()
|
||||||
|
const releaseSecond = yield* Deferred.make<void>()
|
||||||
|
responseStreams = [
|
||||||
|
Stream.fromIterable(reply.tool("call-echo", "echo", { text: "background started" })),
|
||||||
|
Stream.unwrap(
|
||||||
|
Deferred.succeed(secondStarted, undefined).pipe(
|
||||||
|
Effect.andThen(Deferred.await(releaseSecond)),
|
||||||
|
Effect.as(Stream.fromIterable(reply.stop())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Stream.fromIterable(reply.text("Handled completion", "text-completion")),
|
||||||
|
]
|
||||||
|
yield* admit(session, "Start background work")
|
||||||
|
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
||||||
|
yield* Deferred.await(secondStarted)
|
||||||
|
|
||||||
|
yield* session.synthetic({ sessionID, text: "Background work completed" })
|
||||||
|
yield* Deferred.succeed(releaseSecond, undefined)
|
||||||
|
yield* Fiber.join(running)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(3)
|
||||||
|
expect(userTexts(requests[2])).toContain("Background work completed")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("streams one request with registry definitions from chronological V2 user history", () =>
|
it.effect("streams one request with registry definitions from chronological V2 user history", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue