diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 06f09f9af1..ba06226b9c 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -1232,54 +1232,42 @@ const serviceLayer = Layer.effect( // information. A differing summary (new results committed // between attempts) always prompts. if (deliveredWakeSummaries.size > 1024) deliveredWakeSummaries.clear() - const didDeliver = Option.getOrElse( - yield* automation.use( - wakeLease, - Effect.gen(function* () { - if (deliveredWakeSummaries.get(sessionID) !== summary) { - const delivered = yield* promptSvc.promptIfIdle({ - sessionID: SessionID.make(sessionID), - parts: [{ type: "text", text: summary, synthetic: true }], - }) - if (Option.isNone(delivered)) return false - // Record BEFORE the mark: the transcript part was - // already written (the prompt just succeeded), so the - // retry must skip the prompt even when the mark below - // fails again. - deliveredWakeSummaries.set(sessionID, summary) - } - yield* store.markWakeBatchReported(batch).pipe( - Effect.tap(() => - Effect.forEach( - batch.workflows.filter((workflow) => - isWorkflowTerminalStatus(workflow.status as never), - ), - (workflow) => - automation.unregister(SessionID.make(sessionID), { - kind: "dag", - id: workflow.id, - }), - { discard: true }, - ), - ), - Effect.tap(() => - Effect.sync(() => { - plan.unresponsiveDagIDs.forEach((workflowID) => - deliveredUnresponsiveDagIDs.add(workflowID), - ) - }), - ), - ) - return true - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning("DAG wake delivery failed", { sessionID, cause: Cause.pretty(cause) }).pipe( - Effect.as(false), - ), + const didDeliver = yield* Effect.gen(function* () { + if (deliveredWakeSummaries.get(sessionID) !== summary) { + const delivered = yield* SessionPrompt.admitIfIdle(promptSvc, automation, wakeLease, { + sessionID: SessionID.make(sessionID), + parts: [{ type: "text", text: summary, synthetic: true }], + }) + if (Option.isNone(delivered)) return false + deliveredWakeSummaries.set(sessionID, summary) + yield* delivered.value.pipe( + Effect.onError(() => + Effect.sync(() => { + if (deliveredWakeSummaries.get(sessionID) === summary) { + deliveredWakeSummaries.delete(sessionID) + } + }), ), + ) + } + + const markLease = yield* automation.claim(SessionID.make(sessionID), { kind: "dag" }) + if (Option.isNone(markLease)) return false + const marked = yield* automation.use(markLease.value, store.markWakeBatchReported(batch)) + if (Option.isNone(marked)) return false + plan.unresponsiveDagIDs.forEach((workflowID) => deliveredUnresponsiveDagIDs.add(workflowID)) + yield* Effect.forEach( + batch.workflows.filter((workflow) => isWorkflowTerminalStatus(workflow.status as never)), + (workflow) => automation.unregister(SessionID.make(sessionID), { kind: "dag", id: workflow.id }), + { discard: true }, + ) + return true + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("DAG wake delivery failed", { sessionID, cause: Cause.pretty(cause) }).pipe( + Effect.as(false), ), ), - () => false, ) if (!didDeliver) return } diff --git a/packages/opencode/src/goal/CONTEXT.md b/packages/opencode/src/goal/CONTEXT.md index 449914813a..de912d9f3f 100644 --- a/packages/opencode/src/goal/CONTEXT.md +++ b/packages/opencode/src/goal/CONTEXT.md @@ -21,7 +21,7 @@ Standing Goal keeps one durable autonomous objective for a Session and advances - Terminal completion writes `goal_outcome` and deletes the current row in one transition; a durable `done` row is never an intermediate cleanup obligation. - `blocked` pauses the Goal and remains distinguishable from `done` in state, events, transcript text, and judge prompts. - `SessionAutomationLease` elects one automation owner per Session. DAG owns the Session while any registered workflow remains; Goal is eligible only after the final DAG owner releases it. -- Goal and DAG effects revalidate the claimed generation immediately before mutation or prompt admission. `SessionPrompt.promptIfIdle` remains the final idle-state guard. +- Goal and DAG hold the claimed generation fence through a durable mutation or prompt admission. Provider execution starts only after that fence is released; `SessionPrompt.promptIfIdle` remains the final idle-state guard. - The current Session runner is process-local, so the automation lease is process-local. Clustered execution requires a separate durable lease design. ## Boundaries diff --git a/packages/opencode/src/goal/docs/adr/0001-goal-transition-authority.md b/packages/opencode/src/goal/docs/adr/0001-goal-transition-authority.md index 6f29f22716..ce91e4dabc 100644 --- a/packages/opencode/src/goal/docs/adr/0001-goal-transition-authority.md +++ b/packages/opencode/src/goal/docs/adr/0001-goal-transition-authority.md @@ -19,7 +19,7 @@ A `done` verdict writes an immutable `goal_outcome` snapshot and deletes the cur Judge output is tri-state: `done`, `continue`, or `blocked`. `blocked` writes a paused Goal with the blocker as its reason. -`SessionAutomationLease` is the process-local authority for Goal/DAG ownership. Goal and DAG register their active identities; DAG has priority while any workflow is registered. A claim carries a generation that is revalidated immediately before a state transition or autonomous prompt. Registration changes invalidate older claims. After that ownership check, `SessionPrompt.promptIfIdle` remains the final atomic idle-state admission guard. Failure at either boundary admits no Goal prompt and leaves the durable Goal available for a later idle event. +`SessionAutomationLease` is the process-local authority for Goal/DAG ownership. Goal and DAG register their active identities; DAG has priority while any workflow is registered. A claim carries a generation, and the lease holds its per-Session fence through the durable transition or prompt admission. Registration changes cannot overtake that commit. Provider execution starts after the fence is released, so a slow model turn does not block ownership transfer. `SessionPrompt.promptIfIdle` remains the final atomic idle-state admission guard. Failure at either boundary admits no Goal prompt and leaves the durable Goal available for a later idle event. ## Consequences diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index 792d130d75..39d1be8590 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -425,12 +425,14 @@ const serviceLayer = Layer.effect( // clearFiber — us — mid-publish; see the preempt branches above). const continuationLease = Option.getOrUndefined(yield* automation.claim(sessionID, goalOwner)) if (!continuationLease) return - yield* automation.use( - continuationLease, - promptSvc.promptIfIdle({ + yield* Effect.gen(function* () { + const admitted = yield* SessionPrompt.admitIfIdle(promptSvc, automation, continuationLease, { sessionID, parts: [{ type: "text", text: continuationText }], - }).pipe( + }) + if (Option.isNone(admitted)) return + yield* admitted.value + }).pipe( Effect.catchCause((cause) => Effect.gen(function* () { // F1: Only pause for non-interrupt causes. An interrupt (user @@ -468,7 +470,6 @@ const serviceLayer = Layer.effect( return Option.none() }), ), - ), ) const afterDispatch = yield* goal.load(sessionID) if (!afterDispatch || afterDispatch.status !== "active") { diff --git a/packages/opencode/src/session/automation-lease.ts b/packages/opencode/src/session/automation-lease.ts index ce839fcef3..1613608c39 100644 --- a/packages/opencode/src/session/automation-lease.ts +++ b/packages/opencode/src/session/automation-lease.ts @@ -16,6 +16,12 @@ export interface Token { readonly generation: number } +export interface AfterFence { + readonly activate: Effect.Effect + readonly result: Effect.Effect + readonly abort: Effect.Effect +} + type Request = | { readonly kind: "goal"; readonly id: string } | { readonly kind: "dag" } @@ -25,6 +31,10 @@ export interface Interface { readonly unregister: (sessionID: SessionID, owner: Owner) => Effect.Effect readonly claim: (sessionID: SessionID, request: Request) => Effect.Effect> readonly use: (token: Token, effect: Effect.Effect) => Effect.Effect, E, R> + readonly handoff: ( + token: Token, + prepare: Effect.Effect>, E2, R2>, + ) => Effect.Effect>, E2, R2> /** Drop every registration and retry obligation for a session (session deletion). */ readonly purgeSession: (sessionID: SessionID) => Effect.Effect } @@ -169,20 +179,45 @@ export const layer = Layer.effect( }) const use: Interface["use"] = Effect.fn("SessionAutomationLease.use")(function* (token, effect) { - const valid = yield* locks.withLock(token.sessionID)( - Effect.sync(() => { + return yield* locks.withLock(token.sessionID)( + Effect.gen(function* () { const current = registrations.get(token.sessionID) const selected = owner(token.sessionID) - return !( + const valid = !( !current || current.generation !== token.generation || selected?.kind !== token.owner.kind || selected.id !== token.owner.id ) + if (!valid) return Option.none() + return Option.some(yield* effect) + }), + ) + }) + + const handoff: Interface["handoff"] = Effect.fn("SessionAutomationLease.handoff")(function* (token, prepare) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const prepared = yield* restore( + locks.withLock(token.sessionID)( + Effect.gen(function* () { + const current = registrations.get(token.sessionID) + const selected = owner(token.sessionID) + if ( + !current || + current.generation !== token.generation || + selected?.kind !== token.owner.kind || + selected.id !== token.owner.id + ) return Option.none() + return yield* prepare + }), + ), + ) + if (Option.isNone(prepared)) return Option.none() + yield* prepared.value.activate.pipe(Effect.onError(() => prepared.value.abort)) + return Option.some(prepared.value.result) }), ) - if (!valid) return Option.none() - return Option.some(yield* effect) }) // GOAL-FP-01-06: session deletion must drop every registration the session @@ -200,7 +235,7 @@ export const layer = Layer.effect( ) }) - return Service.of({ register, unregister, claim, use, purgeSession }) + return Service.of({ register, unregister, claim, use, handoff, purgeSession }) }), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index cc744bd998..fcc8f5736e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -67,6 +67,7 @@ import { HookStartContext } from "@/hook/start-context" import { Goal } from "@/goal/goal" import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" import { Memory } from "@/memory/memory" +import { SessionAutomationLease } from "./automation-lease" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -113,6 +114,7 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect + readonly prepareIfIdle: (input: PromptInput) => Effect.Effect, Image.Error> readonly promptIfIdle: (input: PromptInput) => Effect.Effect, Image.Error> readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect @@ -120,6 +122,12 @@ export interface Interface { readonly resolvePromptParts: (template: string) => Effect.Effect } +export interface IdleAdmission { + readonly activate: Effect.Effect + readonly result: Effect.Effect + readonly abort: Effect.Effect +} + export class Service extends Context.Service()("@opencode/SessionPrompt") {} export const layer = Layer.effect( @@ -1386,11 +1394,12 @@ export const layer = Layer.effect( return yield* wait }) - const promptIfIdle: Interface["promptIfIdle"] = Effect.fn("SessionPrompt.promptIfIdle")( + const prepareIfIdle: Interface["prepareIfIdle"] = Effect.fn("SessionPrompt.prepareIfIdle")( function* (input: PromptInput) { - const wait = yield* promptLocks.withLock(input.sessionID)( + return yield* promptLocks.withLock(input.sessionID)( Effect.uninterruptibleMask((restore) => Effect.gen(function* () { + const activation = yield* Deferred.make() const admission = yield* Deferred.make< Exit.Exit<{ readonly message: SessionV1.WithParts; readonly run: boolean }, Image.Error> >() @@ -1398,26 +1407,44 @@ export const layer = Layer.effect( input.sessionID, lastAssistant(input.sessionID), Effect.gen(function* () { + yield* Deferred.await(activation) const admitted = yield* Deferred.await(admission) if (Exit.isFailure(admitted)) return yield* Effect.failCause(admitted.cause) if (!admitted.value.run) return admitted.value.message return yield* runLoop(input.sessionID) }).pipe(Effect.orDie), ) - if (Option.isNone(wait)) return wait + if (Option.isNone(wait)) return Option.none() const admitted = yield* restore(admitPrompt(input)).pipe(Effect.exit) yield* Deferred.succeed(admission, admitted) - if (Exit.isFailure(admitted)) return yield* Effect.failCause(admitted.cause) - return wait + if (Exit.isFailure(admitted)) { + yield* Deferred.succeed(activation, undefined) + return yield* Effect.failCause(admitted.cause) + } + return Option.some({ + activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid), + result: wait.value, + abort: state.cancel(input.sessionID), + }) }), ), ) - if (Option.isNone(wait)) return Option.none() - return Option.some(yield* wait.value) }, ) + const promptIfIdle: Interface["promptIfIdle"] = Effect.fn("SessionPrompt.promptIfIdle")( + (input: PromptInput) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const prepared = yield* restore(prepareIfIdle(input)) + if (Option.isNone(prepared)) return Option.none() + yield* prepared.value.activate.pipe(Effect.onError(() => prepared.value.abort)) + return Option.some(yield* restore(prepared.value.result)) + }), + ), + ) + const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user").pipe(Effect.orDie) if (Option.isSome(match)) return match.value @@ -2097,6 +2124,7 @@ export const layer = Layer.effect( return Service.of({ cancel, prompt, + prepareIfIdle, promptIfIdle, loop, shell, @@ -2295,4 +2323,13 @@ export const node = LayerNode.make(layer, [ HookStartContext.node, SettingsHook.node, Goal.node, ]) +export function admitIfIdle( + service: Interface, + automation: SessionAutomationLease.Interface, + token: SessionAutomationLease.Token, + input: PromptInput, +): Effect.Effect>, Image.Error> { + return automation.handoff(token, service.prepareIfIdle(input)) +} + export * as SessionPrompt from "./prompt" diff --git a/packages/opencode/test/dag/dag-adoption-step-races.test.ts b/packages/opencode/test/dag/dag-adoption-step-races.test.ts index 074f2add11..8fe4743b44 100644 --- a/packages/opencode/test/dag/dag-adoption-step-races.test.ts +++ b/packages/opencode/test/dag/dag-adoption-step-races.test.ts @@ -19,6 +19,7 @@ import { MessageID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" interface PromptGate { readonly title: string @@ -100,7 +101,7 @@ function raceLayer(input: { }), messages: (value) => input.messages(value as never) as never, }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: (sessionID) => Effect.sync(() => void input.cancelled.push(sessionID as string)), prompt: Effect.fn("test.SessionPrompt.prompt")(function* (value: SessionPrompt.PromptInput) { const sessionID = value.sessionID as string @@ -113,7 +114,7 @@ function raceLayer(input: { }), // Keep wake delivery pending so the tests observe scheduling only. promptIfIdle: () => Effect.succeed(Option.none()), - }) + })) const agent = Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", diff --git a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts index 45d62f4080..245676836c 100644 --- a/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts +++ b/packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts @@ -25,6 +25,7 @@ import { SessionPrompt } from "@/session/prompt" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" // GOAL-FP-01-02: the final DAG lease unregister (U2) lands AFTER the wake // turn's idle event. GoalLoop's claim runs on idle while the dag registration @@ -221,11 +222,11 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue; fail yield* Queue.offer(input.childPrompts, { title: childTitles.get(sessionID) ?? sessionID, release }) return reply(sessionID, yield* Deferred.await(release)) }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: () => Effect.void, prompt: deliver, promptIfIdle: (value: SessionPrompt.PromptInput) => deliver(value).pipe(Effect.map(Option.some)), - }) + })) const agent = Layer.mock(Agent.Service, { get: () => Effect.succeed({ diff --git a/packages/opencode/test/dag/dag-lease-lifecycle.test.ts b/packages/opencode/test/dag/dag-lease-lifecycle.test.ts index c8fc4e1aaf..df58e5e1c9 100644 --- a/packages/opencode/test/dag/dag-lease-lifecycle.test.ts +++ b/packages/opencode/test/dag/dag-lease-lifecycle.test.ts @@ -24,6 +24,7 @@ import { SessionPrompt } from "@/session/prompt" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" // GOAL-FP-01-01 / GOAL-FP-01-03: the DAG automation-lease registration lifetime // must be bound to WORKFLOW STATE, not to wake delivery. @@ -164,11 +165,11 @@ function leaseLifecycleLayer(input: { childPrompts: Queue.Queue yield* Queue.offer(input.childPrompts, { title: childTitles.get(sessionID) ?? sessionID, release }) return reply(sessionID, yield* Deferred.await(release)) }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: () => Effect.void, prompt: deliver, promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), - }) + })) const agent = Layer.mock(Agent.Service, { get: () => Effect.succeed({ diff --git a/packages/opencode/test/dag/dag-loop-guards.test.ts b/packages/opencode/test/dag/dag-loop-guards.test.ts index 7b461f74af..b2b4ab1d8f 100644 --- a/packages/opencode/test/dag/dag-loop-guards.test.ts +++ b/packages/opencode/test/dag/dag-loop-guards.test.ts @@ -37,6 +37,7 @@ import { MessageID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" interface PromptGate { readonly title: string @@ -140,14 +141,14 @@ function guardLayer(input: { }) return reply(sessionID, yield* Deferred.await(release)) }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: (sessionID) => Effect.sync(() => { input.cancels.push(sessionID as string) }), prompt: deliver, promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), - }) + })) const agent = Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", diff --git a/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts b/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts index ca17578d6c..e5592afb71 100644 --- a/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts +++ b/packages/opencode/test/dag/dag-loop-recovery-integration.test.ts @@ -17,6 +17,7 @@ import { SessionPrompt } from "@/session/prompt" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" type ChildStatus = "active" | "completed" | "failed" | "unknown" @@ -71,14 +72,14 @@ function recoveryLayer(input: { return Effect.succeed([{ info: { role: "assistant", finish: "stop" } }] as never) }), }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: Effect.fn("test.SessionPrompt.cancel")((sessionID: string) => Effect.sync(() => input.cancelled.push(sessionID)), ), // Keep wake delivery pending so tests can inspect durable unreported rows. prompt: () => Effect.never, promptIfIdle: () => Effect.succeed(Option.none()), - }) + })) const loop = DagLoop.layer.pipe( Layer.provide(base), Layer.provide(session), diff --git a/packages/opencode/test/dag/dag-orphan-pending-recovery.test.ts b/packages/opencode/test/dag/dag-orphan-pending-recovery.test.ts index 4e9b412d29..123ba50265 100644 --- a/packages/opencode/test/dag/dag-orphan-pending-recovery.test.ts +++ b/packages/opencode/test/dag/dag-orphan-pending-recovery.test.ts @@ -16,6 +16,7 @@ import { SessionPrompt } from "@/session/prompt" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" const ORPHAN_REASON = "orphan pending workflow recovered at startup" @@ -41,14 +42,14 @@ function orphanRecoveryLayer(input: { promptCalls: string[] }) { get: Effect.fn("test.Session.get")(() => Effect.succeed({} as never)), messages: Effect.fn("test.Session.messages")(() => Effect.succeed([])), }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: Effect.fn("test.SessionPrompt.cancel")(() => Effect.void), prompt: Effect.fn("test.SessionPrompt.prompt")(() => { input.promptCalls.push("prompt") return Effect.never }), promptIfIdle: () => Effect.succeed(Option.none()), - }) + })) const loop = DagLoop.layer.pipe( Layer.provide(base), Layer.provide(session), diff --git a/packages/opencode/test/dag/dag-recovery-escalated-loop.test.ts b/packages/opencode/test/dag/dag-recovery-escalated-loop.test.ts index 3a7f266688..f797f0b272 100644 --- a/packages/opencode/test/dag/dag-recovery-escalated-loop.test.ts +++ b/packages/opencode/test/dag/dag-recovery-escalated-loop.test.ts @@ -17,6 +17,7 @@ import { SessionPrompt } from "@/session/prompt" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" function node(id: string, timeoutMs?: number): NodeConfig { return { @@ -74,7 +75,7 @@ function recoveryLayer(input: { wakes: string[] }) { get: () => Effect.succeed({} as never), messages: () => Effect.succeed([]), }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: () => Effect.void, prompt: () => Effect.never, promptIfIdle: (value) => @@ -84,7 +85,7 @@ function recoveryLayer(input: { wakes: string[] }) { }).pipe( Effect.map(() => Option.some(reply(value.sessionID as string, "wake handled"))), ), - }) + })) const loop = DagLoop.layer.pipe( Layer.provide(base), Layer.provide(session), diff --git a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts index f19067b952..6c9de51b77 100644 --- a/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts +++ b/packages/opencode/test/dag/dag-replan-stale-nodefailed.test.ts @@ -17,6 +17,7 @@ import { MessageID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" interface PromptGate { readonly title: string @@ -118,11 +119,11 @@ function loopLayer(input: { }) return reply(sessionID, yield* Deferred.await(release)) }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: () => Effect.void, prompt: deliver, promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), - }) + })) const agent = Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", diff --git a/packages/opencode/test/dag/dag-timeout-escalation.test.ts b/packages/opencode/test/dag/dag-timeout-escalation.test.ts index 1ba5489df6..f8c88f46a8 100644 --- a/packages/opencode/test/dag/dag-timeout-escalation.test.ts +++ b/packages/opencode/test/dag/dag-timeout-escalation.test.ts @@ -21,6 +21,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" interface PromptGate { readonly title: string @@ -163,11 +164,11 @@ function loopLayer(input: { }) return reply(sessionID, yield* Deferred.await(release)) }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: () => Effect.sync(() => { cancelCount++ }), prompt: deliver, promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), - }) + })) const agent = Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 7417be2129..47e916fa46 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -18,10 +18,11 @@ import { DagLoop } from "@/dag/runtime/loop" import { InstanceRef } from "@/effect/instance-ref" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionPrompt } from "@/session/prompt" -import { MessageID } from "@/session/schema" +import { MessageID, SessionID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" import { pollWithTimeout, testEffect } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" const integration = testEffect(Layer.empty) @@ -146,11 +147,11 @@ function wakeLayer(input: { }) return reply(sessionID, yield* Deferred.await(release)) }) - const prompt = Layer.mock(SessionPrompt.Service, { + const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({ cancel: () => Effect.void, prompt: deliver, promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), - }) + })) const agent = Layer.mock(Agent.Service, { get: () => Effect.succeed({ name: "build", @@ -1048,6 +1049,34 @@ describe("DagLoop atomic wake integration", () => { ) }) + it("retries the parent prompt after a provider failure instead of silently marking the wake", async () => { + await Effect.runPromise( + runWakeTest(({ dag, store, status, childPrompts, parentPrompts, parentSettled }) => + Effect.gen(function* () { + yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Retryable workflow", + config: { name: "retryable", nodes: [node("retryable-node")] }, + }) + const child = yield* takeWithin(childPrompts, "retryable node did not start") + yield* Deferred.succeed(child.release, "retry me") + const first = yield* takeWithin(parentPrompts, "retryable batch did not wake the parent") + yield* Deferred.succeed(first.release, "failure") + yield* takeWithin(parentSettled, "failed parent prompt did not settle") + yield* status.set(SessionID.make("ses_parent"), { type: "idle" }) + + const second = yield* takeWithin(parentPrompts, "failed provider wake was not prompted again") + expect(promptText(second.input)).toContain('Node "retryable-node" completed: retry me') + yield* Deferred.succeed(second.release, "success") + yield* takeWithin(parentSettled, "successful retry did not settle") + expect(yield* store.getUnreportedWakeNodes("ses_parent")).toHaveLength(0) + expect(yield* store.getUnreportedWakeWorkflows("ses_parent")).toHaveLength(0) + }), + ), + ) + }) + it("redelivers an unreported durable batch during startup", async () => { await Effect.runPromise( runWakeTest( diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index ec5d68fc15..144b1c70ed 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -20,6 +20,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { TestInstance } from "../fixture/fixture" import { logLines } from "effect/testing/TestConsole" import { testEffect, pollWithTimeout } from "../lib/effect" +import { withIdleAdmission } from "../lib/session-prompt" // P2b: full-cycle Goal regression (D5). Drives set → idle → judge(continue) → // continuation → idle → judge(done) → terminal event sequence, with the judge @@ -77,19 +78,19 @@ const mkAssistantTools = () => // the mock; the goal state and event captures are the observable contract. const recordingPrompt = (sink: { noReply?: boolean; text: string }[]) => Layer.succeed(SessionPrompt.Service, (() => { - const record = (input: { noReply?: boolean; parts?: Array<{ type: string; text: string }> }) => + const record = (input: SessionPrompt.PromptInput) => Effect.sync(() => { sink.push({ noReply: input.noReply, - text: input.parts?.map((p) => p.text).join("\n") ?? "", + text: input.parts.map((part) => (part.type === "text" ? part.text : "")).join("\n"), }) return undefined as never }) - return { + return withIdleAdmission({ prompt: record, - promptIfIdle: (input: { noReply?: boolean; parts?: Array<{ type: string; text: string }> }) => + promptIfIdle: (input: SessionPrompt.PromptInput) => record(input).pipe(Effect.map(Option.some)), - } as never + }) as never })()) describe("GoalLoop end-to-end — continue → done lifecycle (P2b)", () => { @@ -108,19 +109,19 @@ describe("GoalLoop end-to-end — continue → done lifecycle (P2b)", () => { messages: () => Effect.succeed([mkAssistant()]), } as never) const promptMock = Layer.succeed(SessionPrompt.Service, (() => { - const record = (input: { noReply?: boolean; parts?: Array<{ type: string; text: string }> }) => + const record = (input: SessionPrompt.PromptInput) => Effect.sync(() => { promptCalls.push({ noReply: input.noReply, - text: input.parts?.map((p) => p.text).join("\n") ?? "", + text: input.parts.map((part) => (part.type === "text" ? part.text : "")).join("\n"), }) return undefined as never }) - return { + return withIdleAdmission({ prompt: record, - promptIfIdle: (input: { noReply?: boolean; parts?: Array<{ type: string; text: string }> }) => + promptIfIdle: (input: SessionPrompt.PromptInput) => record(input).pipe(Effect.map(Option.some)), - } as never + }) as never })()) const providerMock = Layer.succeed(Provider.Service, {} as never) const judgeMock = Layer.succeed( @@ -203,7 +204,7 @@ describe("GoalLoop — shared Session automation lease", () => { const sessionMock = Layer.succeed(Session.Service, { messages: () => Effect.succeed([mkAssistant()]), } as never) - const promptMock = Layer.succeed(SessionPrompt.Service, { + const promptMock = Layer.succeed(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.sync(() => { directPromptAttempts += 1 @@ -214,7 +215,7 @@ describe("GoalLoop — shared Session automation lease", () => { leaseAttempts += 1 return Option.none() }), - } as never) + }) as never) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ @@ -266,14 +267,14 @@ describe("GoalLoop + DAG owner arbitration", () => { const sessionMock = Layer.succeed(Session.Service, { messages: () => Effect.succeed([mkAssistant()]), } as never) - const promptMock = Layer.succeed(SessionPrompt.Service, { + const promptMock = Layer.succeed(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.succeed(undefined as never), promptIfIdle: () => Effect.sync(() => { continuationCalls += 1 return Option.some(undefined as never) }), - } as never) + }) as never) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ @@ -369,7 +370,7 @@ describe("GoalLoop — dag release must not double-evaluate a boundary (GOAL-FP- // Second continuation dispatch parks on a gate: under the unfixed // re-trigger the boundary fiber commits (turns 1 → 2) and reaches the gate; // the test then observes the settled double-commit state. - const promptMock = Layer.mock(SessionPrompt.Service, { + const promptMock = Layer.mock(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.die("the direct prompt path is not exercised in this scenario"), promptIfIdle: () => Effect.sync(() => { @@ -384,7 +385,7 @@ describe("GoalLoop — dag release must not double-evaluate a boundary (GOAL-FP- }), Effect.map(() => Option.none()), ), - }) + })) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ @@ -483,10 +484,10 @@ describe("GoalLoop — continuation dispatch failure → recoverable pause (D1)" messages: () => Effect.succeed([mkAssistant()]), } as never) // Always-failing prompt — simulates provider fault / session write error. - const promptFailMock = Layer.succeed(SessionPrompt.Service, { + const promptFailMock = Layer.succeed(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.fail(new Error("continuation provider down")), promptIfIdle: () => Effect.fail(new Error("continuation provider down")), - } as never) + }) as never) const providerMock = Layer.succeed(Provider.Service, {} as never) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, @@ -604,14 +605,14 @@ describe("GoalLoop — dispatch failure releases the lease without the trailing // drops the goal_state table — so afterDispatch's goal.load defects: the // lease release must NOT depend on that trailing load. The die after the // gate is swallowed by the handler's Effect.ignore. - const promptFailAndParkMock = Layer.mock(SessionPrompt.Service, { + const promptFailAndParkMock = Layer.mock(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.gen(function* () { yield* Deferred.await(promptGate) return yield* Effect.die("failure-path prompt is the last stop before the trailing load") }), promptIfIdle: () => Effect.die(new Error("continuation provider down")), - }) + })) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ @@ -718,15 +719,17 @@ describe("GoalLoop — real SessionRunState admission seam (GOAL-FP-01-13)", () // real. The mock returns Option.none() even on admission — afterIdle // discards the promptIfIdle result (only its failure matters), and // admission is observable through the real status flip and the counters. - const promptIfIdle = Effect.fn("test.goalSeam.SessionPrompt.promptIfIdle")(function* ( + const prepareIfIdle = Effect.fn("test.goalSeam.SessionPrompt.prepareIfIdle")(function* ( input: SessionPrompt.PromptInput, ) { const runState = yield* Effect.serviceOption(SessionRunState.Service) if (Option.isNone(runState)) return yield* Effect.die("SessionRunState not provided to the seam mock") + const activation = yield* Deferred.make() const admitted = yield* runState.value.startIfIdle( input.sessionID, Effect.die("onInterrupt is not exercised in this scenario"), Effect.gen(function* () { + yield* Deferred.await(activation) admissions += 1 if (admissions === 1) { firstAdmissionParked = true @@ -739,13 +742,23 @@ describe("GoalLoop — real SessionRunState admission seam (GOAL-FP-01-13)", () rejectedAdmissions += 1 return Option.none() } - // Await the run's completion (the Cancelled exit is captured) so the - // mock's promptIfIdle stays faithful to the real one's waiting behavior. - yield* admitted.value.pipe(Effect.exit, Effect.asVoid) - return Option.none() + return Option.some({ + activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid), + result: admitted.value.pipe(Effect.exit, Effect.as(mkAssistant())), + abort: runState.value.cancel(input.sessionID), + }) + }) + const promptIfIdle = Effect.fn("test.goalSeam.SessionPrompt.promptIfIdle")(function* ( + input: SessionPrompt.PromptInput, + ) { + const prepared = yield* prepareIfIdle(input) + if (Option.isNone(prepared)) return Option.none() + yield* prepared.value.activate + return Option.some(yield* prepared.value.result) }) const promptMock = Layer.mock(SessionPrompt.Service, { prompt: () => Effect.die("the direct prompt path is not exercised in this scenario"), + prepareIfIdle, promptIfIdle, }) const judgeMock = Layer.succeed( @@ -1070,10 +1083,10 @@ describe("GoalLoop — continuation interrupted → no pause, goal stays active // undefined id — the F1 miss case that Cause.interruptors silently drops and // the old interruptors().size check misclassified as a dispatch failure. let interruptCause: Cause.Cause = Cause.interrupt(0) - const promptInterruptMock = Layer.succeed(SessionPrompt.Service, { + const promptInterruptMock = Layer.succeed(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.failCause(interruptCause), promptIfIdle: () => Effect.failCause(interruptCause), - } as never) + }) as never) const providerMock = Layer.succeed(Provider.Service, {} as never) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, @@ -1206,14 +1219,14 @@ describe("GoalLoop — startup scan resumes pre-boot active goals (GOAL-FP-01-04 const sessionMock = Layer.mock(Session.Service, { messages: () => Effect.succeed([mkAssistant()]), }) - const promptMock = Layer.mock(SessionPrompt.Service, { + const promptMock = Layer.mock(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.die("the direct prompt path is not exercised in this scenario"), promptIfIdle: () => Effect.sync(() => { continuationCalls += 1 return Option.none() }), - }) + })) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ @@ -1401,10 +1414,10 @@ describe("GoalLoop — startup scan scoping and hardening (GOAL-FP-01-04 follow- const sessionMock = Layer.mock(Session.Service, { messages: () => Effect.succeed([mkAssistant()]), }) - const promptMock = Layer.mock(SessionPrompt.Service, { + const promptMock = Layer.mock(SessionPrompt.Service, withIdleAdmission({ prompt: () => Effect.die("the direct prompt path is not exercised in this scenario"), promptIfIdle: () => Effect.sync(() => Option.none()), - }) + })) const judgeMock = Layer.succeed( GoalLoopJudgeLLM, GoalLoopJudgeLLM.of({ diff --git a/packages/opencode/test/lib/session-prompt.ts b/packages/opencode/test/lib/session-prompt.ts new file mode 100644 index 0000000000..88dd287b27 --- /dev/null +++ b/packages/opencode/test/lib/session-prompt.ts @@ -0,0 +1,28 @@ +import { Cause, Effect, Option } from "effect" +import { SessionPrompt } from "@/session/prompt" +import { SessionV1 } from "@opencode-ai/core/v1/session" + +export function withIdleAdmission( + service: Value & { + readonly promptIfIdle: ( + input: SessionPrompt.PromptInput, + ) => Effect.Effect, Error> + }, +) { + return { + ...service, + prepareIfIdle: (input: SessionPrompt.PromptInput) => + Effect.succeed( + Option.some({ + activate: Effect.void, + result: service.promptIfIdle(input).pipe( + Effect.flatMap(Option.match({ onNone: () => Effect.interrupt, onSome: Effect.succeed })), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.die(Cause.squash(cause)), + ), + ), + abort: Effect.void, + }), + ), + } +} diff --git a/packages/opencode/test/session/automation-lease.test.ts b/packages/opencode/test/session/automation-lease.test.ts index a40595d766..bc035c4082 100644 --- a/packages/opencode/test/session/automation-lease.test.ts +++ b/packages/opencode/test/session/automation-lease.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Layer, Option, Schema } from "effect" +import { Deferred, Effect, Fiber, Layer, Option, Schema } from "effect" import { SessionAutomationLease } from "@/session/automation-lease" import { SessionID } from "@/session/schema" import { SessionStatus } from "@/session/status" @@ -31,6 +31,135 @@ describe("SessionAutomationLease", () => { }), ) + it.instance("DAG cannot claim before a Goal fenced commit finishes", () => + Effect.gen(function* () { + const lease = yield* SessionAutomationLease.Service + const sessionID = SessionID.descending() + const goal = { kind: "goal" as const, id: "goal-1" } + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + let dagClaimedBeforeCommit = false + + yield* lease.register(sessionID, goal) + const token = Option.getOrThrow(yield* lease.claim(sessionID, goal)) + const commit = yield* lease.use( + token, + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ), + ).pipe(Effect.forkChild) + + yield* Deferred.await(entered) + const dag = yield* Effect.gen(function* () { + yield* lease.register(sessionID, { kind: "dag", id: "dag-1" }) + dagClaimedBeforeCommit = Option.isSome(yield* lease.claim(sessionID, { kind: "dag" })) + }).pipe(Effect.forkChild) + yield* Effect.yieldNow + expect(dagClaimedBeforeCommit).toBe(false) + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(commit) + yield* Fiber.join(dag) + expect(dagClaimedBeforeCommit).toBe(true) + }), + ) + + it.instance("handoff activates outside the fence", () => + Effect.gen(function* () { + const lease = yield* SessionAutomationLease.Service + const sessionID = SessionID.descending() + const goal = { kind: "goal" as const, id: "goal-1" } + const activationEntered = yield* Deferred.make() + const releaseActivation = yield* Deferred.make() + + yield* lease.register(sessionID, goal) + const token = Option.getOrThrow(yield* lease.claim(sessionID, goal)) + const handoff = yield* lease.handoff( + token, + Effect.succeed(Option.some({ + activate: Deferred.succeed(activationEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseActivation)), + ), + result: Effect.void, + abort: Effect.void, + })), + ).pipe(Effect.forkChild) + + yield* Deferred.await(activationEntered) + yield* lease.register(sessionID, { kind: "dag", id: "dag-1" }).pipe( + Effect.timeoutOrElse({ + duration: "250 millis", + orElse: () => Effect.die("activation still held the automation fence"), + }), + Effect.ensuring(Deferred.succeed(releaseActivation, undefined)), + ) + expect(Option.isSome(yield* lease.claim(sessionID, { kind: "dag" }))).toBe(true) + yield* Fiber.join(handoff) + }), + ) + + it.instance("handoff activation survives interruption", () => + Effect.gen(function* () { + const lease = yield* SessionAutomationLease.Service + const sessionID = SessionID.descending() + const goal = { kind: "goal" as const, id: "goal-1" } + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + let activated = false + let aborted = false + + yield* lease.register(sessionID, goal) + const token = Option.getOrThrow(yield* lease.claim(sessionID, goal)) + const handoff = yield* lease.handoff( + token, + Effect.succeed(Option.some({ + activate: Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.sync(() => (activated = true))), + ), + result: Effect.void, + abort: Effect.sync(() => (aborted = true)), + })), + ).pipe(Effect.forkChild) + + yield* Deferred.await(entered) + const interrupted = yield* Fiber.interrupt(handoff).pipe(Effect.forkChild) + yield* Effect.yieldNow + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(interrupted) + expect({ activated, aborted }).toEqual({ activated: true, aborted: false }) + }), + ) + + it.instance("handoff interruption cancels a blocked preparation and releases the fence", () => + Effect.gen(function* () { + const lease = yield* SessionAutomationLease.Service + const sessionID = SessionID.descending() + const goal = { kind: "goal" as const, id: "goal-1" } + const entered = yield* Deferred.make() + let finalized = false + + yield* lease.register(sessionID, goal) + const token = Option.getOrThrow(yield* lease.claim(sessionID, goal)) + const handoff = yield* lease.handoff( + token, + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Effect.sync(() => (finalized = true))), + ), + ).pipe(Effect.forkChild) + + yield* Deferred.await(entered) + yield* Fiber.interrupt(handoff) + yield* lease.register(sessionID, { kind: "dag", id: "dag-1" }).pipe( + Effect.timeoutOrElse({ + duration: "250 millis", + orElse: () => Effect.die("interrupted preparation retained the automation fence"), + }), + ) + expect(finalized).toBe(true) + }), + ) + it.instance("Goal becomes owner again after the final DAG unregisters", () => Effect.gen(function* () { const lease = yield* SessionAutomationLease.Service diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9969f785c9..1d05d4b5a4 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -6,7 +6,7 @@ import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" -import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" import { NamedError } from "@opencode-ai/core/util/error" @@ -1854,6 +1854,39 @@ it.instance("idle-only prompt resolves only after the full provider turn complet }), ) +it.instance("idle-only preparation keeps the provider stopped until activation", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + let releaseTurn: (value: unknown) => void = () => {} + yield* llm.hold("wake handled", new Promise((resolve) => { + releaseTurn = resolve + })) + + const prepared = Option.getOrThrow(yield* prompt.prepareIfIdle({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "fenced synthetic wake", synthetic: true }], + })) + const beforeActivation = yield* llm.wait(1).pipe( + Effect.as("provider-started" as const), + Effect.timeoutOrElse({ + duration: "250 millis", + orElse: () => Effect.succeed("provider-stopped" as const), + }), + ) + expect(beforeActivation).toBe("provider-stopped") + + yield* prepared.activate + yield* awaitWithTimeout(llm.wait(1), "idle-only preparation did not activate the provider") + releaseTurn(undefined) + yield* awaitWithTimeout(prepared.result, "idle-only preparation did not finish") + }), +) + it.instance("prompt submitted during an active run is included in the next LLM input", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg)