diff --git a/.changeset/steer-resume-simplified.md b/.changeset/steer-resume-simplified.md new file mode 100644 index 00000000000..b562bb3873d --- /dev/null +++ b/.changeset/steer-resume-simplified.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input. Recovery-applied moves now end with the same full wake as inbox-admitted moves, retrying any stranded inbox work at the new location. Interrupting with continue now also resumes a next-in-line control item: between-turn manual compaction and moves run under any drain scope, while queued prompts remain parked. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2d0c11b5a55..d0e6a148755 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -791,7 +791,7 @@ const layer = Layer.effect( payload, delivery: input.delivery ?? "steer", }) - const recovered = yield* SessionInbox.serialized( + yield* SessionInbox.serialized( input.sessionID, Effect.gen(function* () { const latest = yield* result.get(input.sessionID) @@ -802,25 +802,16 @@ const layer = Layer.effect( ) const moved = [SessionEvent.Moved, { sessionID: input.sessionID, ...payload }] as const const first = cancellations[0] - if (!first) { - yield* bus.publish(...moved) - return true - } - yield* bus.publishAll([first, ...cancellations.slice(1), moved]) - return true + if (!first) return yield* bus.publish(...moved).pipe(Effect.asVoid) + return yield* bus.publishAll([first, ...cancellations.slice(1), moved]) } yield* SessionInbox.admit(db, bus, { id: SessionMessage.ID.create(), sessionID: input.sessionID, item, }) - return false }), ) - if (recovered) { - yield* execution.wakeActive(input.sessionID) - return - } yield* execution.wake(input.sessionID) }), compact: Effect.fn("Session.compact")(function* (input) { diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index 3da6165a527..2f3738709f8 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -21,8 +21,6 @@ export interface Interface { readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect /** Registers newly recorded work. Repeated wakeups may coalesce. */ readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect - /** Wakes only an active execution, preserving its current input eligibility. */ - readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect /** Interrupt active work owned by this process. Idle interruption is a no-op. */ readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect /** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */ @@ -32,7 +30,7 @@ export interface Interface { /** Routes execution from a Session ID to the runner owned by that Session's Location. */ export class Service extends Context.Service()("@opencode/SessionExecution") {} -type InterruptReason = "user" | "shutdown" | "superseded" +type InterruptReason = "user" | "shutdown" export function terminal(exit: Exit.Exit, reason?: InterruptReason) { if (Exit.isSuccess(exit)) return { type: "succeeded" as const } @@ -113,8 +111,8 @@ export const layer = Layer.effect( return } if (outcome.type === "interrupted") { - // A user cancel (or a superseding execution) releases the claim: the turn must not - // resurrect at the next boot. Shutdown interruption keeps it for restart continuity. + // A user cancel releases the claim: the turn must not resurrect at the next + // boot. Shutdown interruption keeps it for restart continuity. yield* bus.publish( SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason }, @@ -137,16 +135,19 @@ export const layer = Layer.effect( return Service.of({ active: coordinator.active, interrupt: (sessionID, options) => - coordinator.interrupt( - sessionID, - "user", - options?.continue - ? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } } - : undefined, - ), + Effect.gen(function* () { + yield* coordinator.interrupt(sessionID, "user") + if (!options?.continue) return + // Resume steering input and between-turn control work from the interrupted + // intent. Queued next-turn prompts stay parked: a steer-scoped drain never + // promotes them, and a control item behind a queued prompt waits its turn. + const next = yield* SessionInbox.nextPromotable(db, sessionID, "input") + if (next === undefined) return + if (next.delivery === "steer" || next.type === "compaction" || next.type === "move") + yield* coordinator.wake(sessionID, "steer") + }), resume: coordinator.run, wake: coordinator.wake, - wakeActive: coordinator.wakeActive, awaitIdle: coordinator.awaitIdle, }) }), @@ -165,7 +166,6 @@ export const noopLayer = Layer.succeed( active: Effect.succeed(new Set()), resume: () => Effect.void, wake: () => Effect.void, - wakeActive: () => Effect.void, interrupt: () => Effect.void, awaitIdle: () => Effect.void, }), diff --git a/packages/core/src/session/inbox.ts b/packages/core/src/session/inbox.ts index 2ba69fbe889..e41c3db66bb 100644 --- a/packages/core/src/session/inbox.ts +++ b/packages/core/src/session/inbox.ts @@ -333,49 +333,29 @@ export const moveIDs = Effect.fn("SessionInbox.moveIDs")(function* (db: Database .pipe(Effect.orDie) }) -export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, -) { - const row = yield* db - .select() - .from(SessionInboxTable) - .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue"))) - .orderBy(asc(SessionInboxTable.enqueued_seq)) - .limit(1) - .get() - .pipe(Effect.orDie) - return row ? fromRow(row) : undefined -}) - -export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, -) { - const row = yield* db - .select() - .from(SessionInboxTable) - .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer"))) - .orderBy(asc(SessionInboxTable.enqueued_seq)) - .limit(1) - .get() - .pipe(Effect.orDie) - return row ? fromRow(row) : undefined -}) - export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, promotable: Promotable, ) { - return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined) + const next = (delivery: Delivery) => + db + .select() + .from(SessionInboxTable) + .where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery))) + .orderBy(asc(SessionInboxTable.enqueued_seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + const steer = yield* next("steer") + if (steer) return fromRow(steer) + if (promotable !== "input") return undefined + const queued = yield* next("queue") + return queued ? fromRow(queued) : undefined }) -/** - * Which pending rows count: "any" counts every row, while "input" means any - * item in either delivery mode. - */ -export type Scope = "any" | "input" | Delivery +/** Which pending rows count: "input" means any item in either delivery mode. */ +export type Scope = "input" | Delivery export const has = Effect.fn("SessionInbox.has")(function* ( db: DatabaseService, @@ -388,11 +368,9 @@ export const has = Effect.fn("SessionInbox.has")(function* ( .where( and( eq(SessionInboxTable.session_id, sessionID), - scope === "any" - ? undefined - : scope === "input" - ? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue")) - : eq(SessionInboxTable.delivery, scope), + scope === "input" + ? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue")) + : eq(SessionInboxTable.delivery, scope), ), ) .limit(1) diff --git a/packages/core/src/session/run-coordinator.ts b/packages/core/src/session/run-coordinator.ts index 1c9b8c2ba94..e78280fc7c2 100644 --- a/packages/core/src/session/run-coordinator.ts +++ b/packages/core/src/session/run-coordinator.ts @@ -10,25 +10,17 @@ export interface Coordinator { /** Starts an execution while idle, or joins the active execution and returns its exit. */ readonly run: (key: Key) => Effect.Effect /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ - readonly wake: (key: Key, request?: Request) => Effect.Effect - /** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */ - readonly wakeActive: (key: Key) => Effect.Effect + readonly wake: (key: Key, scope?: Promotable) => Effect.Effect /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */ - readonly interrupt: ( - key: Key, - reason?: Reason, - options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect } }, - ) => Effect.Effect + readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ readonly awaitIdle: (key: Key) => Effect.Effect } -export type Request = Promotable - /** * 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 - * execution rings it with its eligibility request, and the execution loop drains again + * execution rings it with the scope that work needs, and the execution loop drains again * instead of ending. The doorbell closes the gap between a drain's last eligibility check * and the idle transition, since those cannot be one atomic step. `done` resolves joiners * with this execution's exit. @@ -36,15 +28,10 @@ export type Request = Promotable type Execution = { readonly done: Deferred.Deferred owner?: Fiber.Fiber - request: Request - pendingWake?: Request + scope: Promotable + pendingWake?: Promotable stopping: boolean interruptionReason?: Reason - continuation?: { - readonly request: Request - readonly when: Effect.Effect - signaled: boolean - } } /** @@ -59,7 +46,7 @@ type Execution = { * ``` */ export const make = (options: { - readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect + readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect /** Runs once when a process-local busy period begins, before its first drain. */ readonly started?: (key: Key) => Effect.Effect /** @@ -73,11 +60,11 @@ export const make = (options: { const fork = yield* FiberSet.makeRuntime() const loop = (key: Key, execution: Execution, force: boolean): Effect.Effect => - Effect.suspend(() => options.drain(key, force, execution.request)).pipe( + Effect.suspend(() => options.drain(key, force, execution.scope)).pipe( Effect.flatMap(() => Effect.suspend(() => { if (execution.stopping || execution.pendingWake === undefined) return Effect.void - execution.request = execution.pendingWake + execution.scope = execution.pendingWake execution.pendingWake = undefined // Trampoline so drains that complete synchronously cannot grow the stack. return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false))) @@ -85,10 +72,10 @@ export const make = (options: { ), ) - const start = (key: Key, force: boolean, request: Request) => { + const start = (key: Key, force: boolean, scope: Promotable) => { const execution: Execution = { done: Deferred.makeUnsafe(), - request, + scope, stopping: false, } executions.set(key, execution) @@ -104,7 +91,7 @@ export const make = (options: { execution.owner = undefined }).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)), ), - Effect.onExit((exit) => finish(key, execution, exit)), + Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))), Effect.exit, Effect.asVoid, ), @@ -114,22 +101,12 @@ export const make = (options: { // 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. - const settle = (key: Key, execution: Execution, exit: Exit.Exit, resume: boolean) => { - if (resume && execution.continuation) start(key, false, execution.continuation.request) - else if (execution.pendingWake) start(key, false, execution.pendingWake) + const settle = (key: Key, execution: Execution, exit: Exit.Exit) => { + if (execution.pendingWake) start(key, false, execution.pendingWake) else executions.delete(key) Deferred.doneUnsafe(execution.done, exit) } - const finish = (key: Key, execution: Execution, exit: Exit.Exit) => { - if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false)) - return execution.continuation.when.pipe( - Effect.flatMap((ready) => - Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)), - ), - ) - } - const run = (key: Key): Effect.Effect => Effect.suspend(() => { const execution = executions.get(key) @@ -141,55 +118,26 @@ export const make = (options: { return Deferred.await(start(key, true, "input").done) }) - const wake = (key: Key, request: Request = "input") => + const wake = (key: Key, scope: Promotable = "input") => Effect.sync(() => { const execution = executions.get(key) if (execution !== undefined) { - if (execution.stopping) { - if (execution.continuation) execution.continuation.signaled = true - else execution.continuation = { request, when: Effect.succeed(true), signaled: true } - return - } - // Coalesced wakes keep the widest request: "input" subsumes "steer". - execution.pendingWake = execution.pendingWake === "input" ? "input" : request + // Coalesced wakes keep the widest scope: "input" subsumes "steer". + execution.pendingWake = execution.pendingWake === "input" ? "input" : scope return } - start(key, false, request) + start(key, false, scope) }) - const wakeActive = (key: Key) => + const interrupt = (key: Key, reason?: Reason): Effect.Effect => Effect.suspend(() => { const execution = executions.get(key) - return execution ? wake(key, execution.request) : Effect.void - }) - - const interrupt = ( - key: Key, - reason?: Reason, - options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect } }, - ): Effect.Effect => - Effect.suspend(() => { - const execution = executions.get(key) - if (execution === undefined) return Effect.void - if (execution.stopping) { - if (options?.continue) - execution.continuation = { - ...options.continue, - signaled: execution.continuation?.signaled ?? false, - } - return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid) - } - if (execution.owner === undefined) { - if (!options?.continue) return Effect.void - execution.stopping = true - execution.pendingWake = undefined - execution.continuation = { ...options.continue, signaled: false } - return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid) - } + if (execution?.owner === undefined || execution.stopping) return Effect.void execution.stopping = true + // Wakes recorded so far belong to the interrupted intent; the interrupt claims them. + // Wakes arriving during cleanup are new admissions and restart normally at settle. execution.pendingWake = undefined execution.interruptionReason = reason - if (options?.continue) execution.continuation = { ...options.continue, signaled: false } return Fiber.interrupt(execution.owner) }) @@ -202,5 +150,5 @@ export const make = (options: { return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key))) }) - return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle } + return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index cb157faf2ec..1bb22dd9301 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -135,26 +135,39 @@ const layer = Layer.effect( let force = input.force let continuation = input.continuation const promotable = input.promotable ?? "input" - if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable))) + if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return { type: "complete" as const } yield* plugins.flush yield* settleStaleToolCalls(input.sessionID) while (true) { - if (yield* runPendingCompaction(input.sessionID, promotable)) { + // Between-turn control items run under any drain scope: scope gates which user + // input may promote, not whether admitted housekeeping runs. Enqueue order still + // holds — a control item behind a queued prompt is not the next eligible item. + if (yield* runPendingCompaction(input.sessionID, "input")) { force = false continue } - if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const } + if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const } if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable))) return { type: "complete" as const } const result = yield* runSteps(input.sessionID, continuation, promotable) if (result.type === "moved") return result - if (promotable === "steer") return { type: "complete" as const } force = false continuation = undefined } }) + /** Work this drain may perform: scoped input, or a between-turn control item next in line. */ + const eligible = Effect.fnUntraced(function* ( + sessionID: SessionSchema.ID, + promotable: SessionInbox.Promotable, + ) { + if (yield* SessionInbox.has(db, sessionID, promotable)) return true + if (promotable === "input") return false + const next = yield* SessionInbox.nextPromotable(db, sessionID, "input") + return next?.type === "compaction" || next?.type === "move" + }) + /** * 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. diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index d41f1416809..11699003093 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -14,8 +14,10 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { UserInterruptedError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionInbox } from "@opencode-ai/core/session/inbox" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunner } from "@opencode-ai/core/session/runner/index" -import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect" import { eq } from "drizzle-orm" @@ -43,7 +45,6 @@ describe("SessionExecution lifecycle", () => { const interrupted = Effect.runSyncExit(Effect.interrupt) expect(SessionExecution.terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" }) expect(SessionExecution.terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" }) - expect(SessionExecution.terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" }) expect(SessionExecution.terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user", @@ -290,6 +291,175 @@ describe("SessionExecution lifecycle", () => { ) }) +describe("SessionExecution interrupt continuation", () => { + it.effect("resumes only steering input after an interrupt with continue", () => + Effect.gen(function* () { + const database = yield* Database.Service + const sessionID = Session.ID.make("ses_continue_steer") + yield* seedSessions(database, [sessionID]) + yield* seedInbox(database, sessionID, ["steer", "queue"]) + + const draining = yield* Deferred.make() + const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = [] + const scope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)) + const context = yield* buildExecution(scope, (input) => + Effect.suspend(() => { + drains.push({ force: input.force, promotable: input.promotable }) + if (drains.length > 1) return Effect.void + return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)) + }), + ) + const execution = Context.get(context, SessionExecution.Service) + yield* execution.resume(sessionID).pipe(Effect.forkScoped) + yield* Deferred.await(draining) + + yield* execution.interrupt(sessionID, { continue: true }) + yield* execution.awaitIdle(sessionID) + + // The successor drain is steer-scoped: queued next-turn work stays parked. + expect(drains).toEqual([ + { force: true, promotable: "input" }, + { force: false, promotable: "steer" }, + ]) + }), + ) + + it.effect("stays parked after an interrupt with continue when only queued work remains", () => + Effect.gen(function* () { + const database = yield* Database.Service + const sessionID = Session.ID.make("ses_continue_parked") + yield* seedSessions(database, [sessionID]) + yield* seedInbox(database, sessionID, ["queue"]) + + const draining = yield* Deferred.make() + const drains: Array = [] + const scope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)) + const context = yield* buildExecution(scope, (input) => + Effect.suspend(() => { + drains.push(input.promotable) + return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)) + }), + ) + const execution = Context.get(context, SessionExecution.Service) + yield* execution.resume(sessionID).pipe(Effect.forkScoped) + yield* Deferred.await(draining) + + yield* execution.interrupt(sessionID, { continue: true }) + yield* execution.awaitIdle(sessionID) + + expect(drains).toEqual(["input"]) + expect(yield* execution.active).toEqual(new Set()) + }), + ) + + it.effect("an idle interrupt with continue resumes pending steers", () => + Effect.gen(function* () { + const database = yield* Database.Service + const sessionID = Session.ID.make("ses_continue_idle") + yield* seedSessions(database, [sessionID]) + yield* seedInbox(database, sessionID, ["steer"]) + + const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = [] + const scope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)) + const context = yield* buildExecution(scope, (input) => + Effect.sync(() => void drains.push({ force: input.force, promotable: input.promotable })), + ) + const execution = Context.get(context, SessionExecution.Service) + + yield* execution.interrupt(sessionID, { continue: true }) + yield* execution.awaitIdle(sessionID) + + expect(drains).toEqual([{ force: false, promotable: "steer" }]) + }), + ) + + it.effect("an interrupt with continue resumes a queued compaction next in line", () => + Effect.gen(function* () { + const database = yield* Database.Service + const sessionID = Session.ID.make("ses_continue_compaction") + yield* seedSessions(database, [sessionID]) + yield* seedInbox(database, sessionID, [{ delivery: "queue", type: "compaction" }]) + + const draining = yield* Deferred.make() + const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = [] + const scope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)) + const context = yield* buildExecution(scope, (input) => + Effect.suspend(() => { + drains.push({ force: input.force, promotable: input.promotable }) + if (drains.length > 1) return Effect.void + return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)) + }), + ) + const execution = Context.get(context, SessionExecution.Service) + yield* execution.resume(sessionID).pipe(Effect.forkScoped) + yield* Deferred.await(draining) + + yield* execution.interrupt(sessionID, { continue: true }) + yield* execution.awaitIdle(sessionID) + + // Control work is housekeeping, not next-turn input: continue runs it. + expect(drains).toEqual([ + { force: true, promotable: "input" }, + { force: false, promotable: "steer" }, + ]) + }), + ) + + it.effect("keeps a control item parked behind a queued prompt on continue", () => + Effect.gen(function* () { + const database = yield* Database.Service + const sessionID = Session.ID.make("ses_continue_control_behind") + yield* seedSessions(database, [sessionID]) + yield* seedInbox(database, sessionID, ["queue", { delivery: "queue", type: "compaction" }]) + + const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = [] + const scope = yield* Scope.make() + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)) + const context = yield* buildExecution(scope, (input) => + Effect.sync(() => void drains.push({ force: input.force, promotable: input.promotable })), + ) + const execution = Context.get(context, SessionExecution.Service) + + yield* execution.interrupt(sessionID, { continue: true }) + yield* execution.awaitIdle(sessionID) + + // The queued prompt is next in line; the compaction behind it waits its turn. + expect(drains).toEqual([]) + }), + ) +}) + +/** Plain deliveries seed user prompts; objects seed control items. */ +function seedInbox( + database: Database.Service["Service"], + sessionID: Session.ID, + items: ReadonlyArray< + SessionInbox.Delivery | { readonly delivery: SessionInbox.Delivery; readonly type: "compaction" } + >, +) { + return database.db + .insert(SessionInboxTable) + .values( + items.map((item, index) => { + const entry = typeof item === "string" ? { delivery: item, type: "user" as const } : item + return { + id: SessionMessage.ID.create(), + session_id: sessionID, + type: entry.type, + payload: entry.type === "user" ? { text: "queued prompt" } : {}, + delivery: entry.delivery, + enqueued_seq: index + 1, + } + }), + ) + .run() + .pipe(Effect.orDie) +} + function seedSessions( database: Database.Service["Service"], sessionIDs: ReadonlyArray, diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index b55b7ee5c51..3d4df854a64 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -53,7 +53,6 @@ const execution = Layer.succeed( Effect.sync(() => { wakeCalls.push(sessionID) }), - wakeActive: () => Effect.void, awaitIdle: () => Effect.void, }), ) @@ -1130,12 +1129,11 @@ describe("Session.inbox", () => { const { db } = yield* Database.Service const barrier = yield* session.compact({ sessionID }) - expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(true) expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true) expect(yield* session.inbox(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }]) yield* session.cancelInbox({ sessionID, inboxID: barrier.id }) - expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(false) + expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(false) expect(yield* session.inbox(sessionID)).toEqual([]) }), ) diff --git a/packages/core/test/session-run-coordinator.test.ts b/packages/core/test/session-run-coordinator.test.ts index 1c3995488ff..ec97a093cf4 100644 --- a/packages/core/test/session-run-coordinator.test.ts +++ b/packages/core/test/session-run-coordinator.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { SessionInbox } from "@opencode-ai/core/session/inbox" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { testEffect } from "./lib/effect" @@ -269,31 +270,24 @@ describe("SessionRunCoordinator", () => { ), ) - it.effect("replaces a settlement-window wake with a steer continuation", () => + it.effect("a settlement-window wake starts a fresh execution with its own scope", () => Effect.scoped( Effect.gen(function* () { const settling = yield* Deferred.make() const release = yield* Deferred.make() - const requests: SessionRunCoordinator.Request[] = [] + const scopes: SessionInbox.Promotable[] = [] const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, _force, request) => Effect.sync(() => requests.push(request)), + drain: (_key, _force, scope) => Effect.sync(() => scopes.push(scope)), settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))), }) - yield* coordinator.wake("session", "input") + yield* coordinator.wake("session", "steer") yield* Deferred.await(settling) yield* coordinator.wake("session", "input") - const interrupted = yield* coordinator - .interrupt("session", undefined, { - continue: { request: "steer", when: Effect.succeed(true) }, - }) - .pipe(Effect.forkChild) - yield* Effect.yieldNow yield* Deferred.succeed(release, undefined) - yield* Fiber.join(interrupted) yield* coordinator.awaitIdle("session") - expect(requests).toEqual(["input", "steer"]) + expect(scopes).toEqual(["steer", "input"]) }), ), ) @@ -371,17 +365,17 @@ describe("SessionRunCoordinator", () => { ), ) - it.effect("coalesces drain requests with input taking precedence", () => + it.effect("coalesces drain scopes with input taking precedence", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() const release = yield* Deferred.make() - const requests: SessionRunCoordinator.Request[] = [] + const scopes: SessionInbox.Promotable[] = [] const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, _force, request) => + drain: (_key, _force, scope) => Effect.gen(function* () { - requests.push(request) - if (requests.length !== 1) return + scopes.push(scope) + if (scopes.length !== 1) return yield* Deferred.succeed(firstStarted, undefined) yield* Deferred.await(release) }), @@ -394,22 +388,22 @@ describe("SessionRunCoordinator", () => { yield* Deferred.succeed(release, undefined) yield* coordinator.awaitIdle("session") - expect(requests).toEqual(["steer", "input"]) + expect(scopes).toEqual(["steer", "input"]) }), ), ) - it.effect("does not carry a completed input request into a steer drain", () => + it.effect("does not carry a completed input scope into a steer drain", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() const release = yield* Deferred.make() - const requests: SessionRunCoordinator.Request[] = [] + const scopes: SessionInbox.Promotable[] = [] const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, _force, request) => + drain: (_key, _force, scope) => Effect.gen(function* () { - requests.push(request) - if (requests.length !== 1) return + scopes.push(scope) + if (scopes.length !== 1) return yield* Deferred.succeed(firstStarted, undefined) yield* Deferred.await(release) }), @@ -421,89 +415,23 @@ describe("SessionRunCoordinator", () => { yield* Deferred.succeed(release, undefined) yield* coordinator.awaitIdle("session") - expect(requests).toEqual(["input", "steer"]) + expect(scopes).toEqual(["input", "steer"]) }), ), ) - it.effect("an active wake inherits scope without starting idle work", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const release = yield* Deferred.make() - const requests: SessionRunCoordinator.Request[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, _force, request) => - Effect.gen(function* () { - requests.push(request) - if (requests.length !== 1) return - yield* Deferred.succeed(firstStarted, undefined) - yield* Deferred.await(release) - }), - }) - - yield* coordinator.wakeActive("session") - yield* coordinator.wake("session", "steer") - yield* Deferred.await(firstStarted) - yield* coordinator.wakeActive("session") - yield* Deferred.succeed(release, undefined) - yield* coordinator.awaitIdle("session") - - expect(requests).toEqual(["steer", "steer"]) - }), - ), - ) - - it.effect("coalesces overlapping interrupt continuations into one steer successor", () => + it.effect("a cleanup-era wake starts a successor with its own scope", () => Effect.scoped( Effect.gen(function* () { const firstStarted = yield* Deferred.make() const cleanupStarted = yield* Deferred.make() const cleanupGate = yield* Deferred.make() - const requests: SessionRunCoordinator.Request[] = [] + const scopes: SessionInbox.Promotable[] = [] const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, _force, request) => + drain: (_key, _force, scope) => Effect.gen(function* () { - requests.push(request) - if (requests.length !== 1) return - yield* Deferred.succeed(firstStarted, undefined) - yield* Effect.never.pipe( - Effect.onInterrupt(() => - Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))), - ), - ) - }), - }) - const continuation = { continue: { request: "steer" as const, when: Effect.succeed(false) } } - - yield* coordinator.wake("session") - yield* Deferred.await(firstStarted) - const first = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild) - yield* Deferred.await(cleanupStarted) - const second = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild) - yield* Effect.yieldNow - yield* coordinator.wake("session", "input") - yield* Deferred.succeed(cleanupGate, undefined) - yield* Effect.all([Fiber.join(first), Fiber.join(second)]) - yield* coordinator.awaitIdle("session") - - expect(requests).toEqual(["input", "steer"]) - }), - ), - ) - - it.effect("a continuing interrupt replaces a cleanup-era input wake", () => - Effect.scoped( - Effect.gen(function* () { - const firstStarted = yield* Deferred.make() - const cleanupStarted = yield* Deferred.make() - const cleanupGate = yield* Deferred.make() - const requests: SessionRunCoordinator.Request[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, _force, request) => - Effect.gen(function* () { - requests.push(request) - if (requests.length !== 1) return + scopes.push(scope) + if (scopes.length !== 1) return yield* Deferred.succeed(firstStarted, undefined) yield* Effect.never.pipe( Effect.onInterrupt(() => @@ -515,45 +443,16 @@ describe("SessionRunCoordinator", () => { yield* coordinator.wake("session", "input") yield* Deferred.await(firstStarted) - const plain = yield* coordinator.interrupt("session").pipe(Effect.forkChild) + const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild) yield* Deferred.await(cleanupStarted) + // A new admission during cancellation restarts normally: interruption only + // claims the wakes recorded before it. yield* coordinator.wake("session", "input") - const continuing = yield* coordinator - .interrupt("session", undefined, { - continue: { request: "steer", when: Effect.succeed(false) }, - }) - .pipe(Effect.forkChild) - yield* Effect.yieldNow yield* Deferred.succeed(cleanupGate, undefined) - yield* Effect.all([Fiber.join(plain), Fiber.join(continuing)]) + yield* Fiber.join(interrupt) yield* coordinator.awaitIdle("session") - expect(requests).toEqual(["input", "steer"]) - }), - ), - ) - - it.effect("does not start a conditional continuation without eligible work", () => - Effect.scoped( - Effect.gen(function* () { - const started = yield* Deferred.make() - const requests: SessionRunCoordinator.Request[] = [] - const coordinator = yield* SessionRunCoordinator.make({ - drain: (_key, _force, request) => - Effect.sync(() => requests.push(request)).pipe( - Effect.andThen(Deferred.succeed(started, undefined)), - Effect.andThen(Effect.never), - ), - }) - - yield* coordinator.wake("session") - yield* Deferred.await(started) - yield* coordinator.interrupt("session", undefined, { - continue: { request: "steer", when: Effect.succeed(false) }, - }) - yield* coordinator.awaitIdle("session") - - expect(requests).toEqual(["input"]) + expect(scopes).toEqual(["input", "input"]) }), ), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 522cf7a05c8..7a9f83bf45d 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -126,7 +126,6 @@ const execution = (llmClient: Layer.Layer) => active: coordinator.active, resume: coordinator.run, wake: coordinator.wake, - wakeActive: coordinator.wakeActive, interrupt: (sessionID) => coordinator.interrupt(sessionID), awaitIdle: coordinator.awaitIdle, }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 9e6d60b9d1e..24928dfe727 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -436,7 +436,6 @@ const execution = Layer.effect( active: coordinator.active, resume: coordinator.run, wake: coordinator.wake, - wakeActive: coordinator.wakeActive, interrupt: (sessionID) => coordinator.interrupt(sessionID), awaitIdle: coordinator.awaitIdle, }) @@ -3214,6 +3213,54 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("a steer-scoped drain runs a queued manual compaction next in line", () => + Effect.gen(function* () { + const session = yield* setup + const { db } = yield* Database.Service + const bus = yield* Bus.Service + // Admit without waking so the steer-scoped drain below is the first consumer. + const compaction = yield* SessionInbox.admitCompaction(db, bus, { + id: SessionMessage.ID.create(), + sessionID, + delivery: "queue", + }) + + const runner = yield* SessionRunner.Service + yield* runner.drain({ sessionID, force: false, promotable: "steer" }) + + // Control work is scope-independent between turns: the barrier is consumed + // even though the drain never promotes queued input. + expect(yield* SessionInbox.find(db, compaction.id)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + error: { type: "compaction.unavailable", message: "Nothing to compact yet" }, + }) + }), + ) + + it.effect("a steer-scoped drain leaves a compaction parked behind a queued prompt", () => + Effect.gen(function* () { + const session = yield* setup + const { db } = yield* Database.Service + const bus = yield* Bus.Service + yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false }) + const compaction = yield* SessionInbox.admitCompaction(db, bus, { + id: SessionMessage.ID.create(), + sessionID, + delivery: "queue", + }) + + const runner = yield* SessionRunner.Service + yield* runner.drain({ sessionID, force: false, promotable: "steer" }) + + // Enqueue order holds: the queued prompt is next in line, so nothing runs. + expect(requests).toHaveLength(0) + expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true) + expect(yield* SessionInbox.find(db, compaction.id)).toMatchObject({ id: compaction.id }) + }), + ) + it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 5d92e8d7008..3d0345fd08b 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -115,7 +115,6 @@ const executionNode = makeGlobalNode({ active: Effect.succeed(new Set()), resume: complete, wake: () => Effect.void, - wakeActive: () => Effect.void, interrupt: () => Effect.void, awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid), }) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 42a3bb81a63..4283e83f01e 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -88,7 +88,6 @@ const executionNode = makeGlobalNode({ active: Effect.succeed(new Set()), resume: complete, wake: () => Effect.void, - wakeActive: () => Effect.void, interrupt: () => Effect.void, awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid), }) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 5c6c9eb18b8..17aad57cf1d 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -660,7 +660,7 @@ export const makeSessionGroup = (sessionLo identifier: "v2.session.interrupt", summary: "Interrupt session execution", description: - "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.", + "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.", }), ), )