mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 15:33:33 +00:00
refactor(core): simplify interrupt continuation (#42810)
This commit is contained in:
parent
6b09b9e6a2
commit
b6966177fa
14 changed files with 330 additions and 284 deletions
5
.changeset/steer-resume-simplified.md
Normal file
5
.changeset/steer-resume-simplified.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ export interface Interface {
|
|||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Wakes only an active execution, preserving its current input eligibility. */
|
||||
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
/** 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<Service, Interface>()("@opencode/SessionExecution") {}
|
||||
|
||||
type InterruptReason = "user" | "shutdown" | "superseded"
|
||||
type InterruptReason = "user" | "shutdown"
|
||||
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, 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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -10,25 +10,17 @@ export interface Coordinator<Key, E, Reason = never> {
|
|||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
|
||||
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
|
||||
readonly wakeActive: (key: Key) => Effect.Effect<void>
|
||||
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
|
||||
/** 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<boolean> } },
|
||||
) => 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. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
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<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
request: Request
|
||||
pendingWake?: Request
|
||||
scope: Promotable
|
||||
pendingWake?: Promotable
|
||||
stopping: boolean
|
||||
interruptionReason?: Reason
|
||||
continuation?: {
|
||||
readonly request: Request
|
||||
readonly when: Effect.Effect<boolean>
|
||||
signaled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -59,7 +46,7 @@ type Execution<E, Reason> = {
|
|||
* ```
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
|
||||
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
|
|
@ -73,11 +60,11 @@ export const make = <Key, E, Reason = never>(options: {
|
|||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||
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 = <Key, E, Reason = never>(options: {
|
|||
),
|
||||
)
|
||||
|
||||
const start = (key: Key, force: boolean, request: Request) => {
|
||||
const start = (key: Key, force: boolean, scope: Promotable) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
request,
|
||||
scope,
|
||||
stopping: false,
|
||||
}
|
||||
executions.set(key, execution)
|
||||
|
|
@ -104,7 +91,7 @@ export const make = <Key, E, Reason = never>(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 = <Key, E, Reason = never>(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<E, Reason>, exit: Exit.Exit<void, E>, 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<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
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<void, E> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
|
|
@ -141,55 +118,26 @@ export const make = <Key, E, Reason = never>(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<void> =>
|
||||
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<boolean> } },
|
||||
): Effect.Effect<void> =>
|
||||
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 = <Key, E, Reason = never>(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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<void>()
|
||||
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<void>()
|
||||
const drains: Array<SessionInbox.Promotable | undefined> = []
|
||||
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<void>()
|
||||
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<Session.ID>,
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
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<void>()
|
||||
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"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -126,7 +126,6 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
|||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue