feat(core): interrupt v2 session execution (#30850)

This commit is contained in:
Kit Langton 2026-06-05 12:06:40 -04:00 committed by GitHub
commit 12e38866ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1047 additions and 112 deletions

View file

@ -17,6 +17,7 @@ describe("public native OpenCode API", () => {
"create",
"events",
"get",
"interrupt",
"list",
"message",
"messages",

View file

@ -23,7 +23,10 @@ const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
const interruptSeqs: Array<number | undefined> = []
const wakeCalls: SessionV2.ID[] = []
const wakeSeqs: Array<number | undefined> = []
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
@ -31,9 +34,15 @@ const execution = Layer.succeed(
Effect.sync(() => {
executionCalls.push(sessionID)
}),
wake: (sessionID) =>
interrupt: (sessionID, seq) =>
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptSeqs.push(seq)
}),
wake: (sessionID, seq) =>
Effect.sync(() => {
wakeCalls.push(sessionID)
wakeSeqs.push(seq)
}),
}),
)
@ -95,6 +104,15 @@ const eventCount = (type: string) =>
),
)
const interruptEvent = Database.Service.use(({ db }) =>
db
.select()
.from(EventTable)
.where(eq(EventTable.type, "session.next.interrupt.requested.1"))
.get()
.pipe(Effect.orDie),
)
describe("SessionV2.prompt", () => {
it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () {
@ -108,6 +126,35 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("delegates interruption through SessionExecution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(sessionID)
expect(interruptCalls).toEqual([sessionID])
expect(interruptSeqs).toHaveLength(1)
expect(typeof interruptSeqs[0]).toBe("number")
expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(1)
expect(yield* interruptEvent).toMatchObject({ aggregate_id: sessionID, seq: interruptSeqs[0] })
expect(yield* session.messages({ sessionID })).toEqual([])
}),
)
it.effect("delegates interruption without requiring a recorded Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(SessionV2.ID.make("ses_missing"))
expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")])
expect(interruptSeqs).toEqual([undefined])
}),
)
it.effect("durably admits one user message before transcript promotion", () =>
Effect.gen(function* () {
yield* setup
@ -513,11 +560,13 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}),
)
@ -527,11 +576,13 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run explicitly" }), resume: true })
const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run explicitly" }), resume: true })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}),
)
@ -541,11 +592,13 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([])
expect(wakeSeqs).toEqual([])
}),
)
})

View file

@ -41,6 +41,536 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("does nothing when interrupted while idle", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
yield* coordinator.interrupt("session")
}),
),
)
it.effect("suppresses stale wakes after an idle interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) })
yield* coordinator.interrupt("session", 2)
yield* coordinator.wake("session", 1)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(0)
yield* coordinator.wake("session", 3)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
}),
),
)
it.effect("does not interrupt a wake newer than the interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const gate = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(gate)),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
})
yield* coordinator.wake("session", 3)
yield* Deferred.await(started)
yield* coordinator.interrupt("session", 2)
expect(yield* Deferred.isDone(interrupted)).toBeFalse()
yield* Deferred.succeed(gate, undefined)
yield* coordinator.awaitIdle("session")
}),
),
)
it.effect("preserves a queued wake newer than the interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session", 1)
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", 3)
yield* coordinator.interrupt("session", 2)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts only the requested key", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const secondInterrupted = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) =>
key === "first"
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(secondGate)),
Effect.onInterrupt(() => Deferred.succeed(secondInterrupted, undefined)),
),
})
yield* coordinator.wake("first")
yield* coordinator.wake("second")
yield* Effect.all([Deferred.await(firstStarted), Deferred.await(secondStarted)])
yield* coordinator.interrupt("first")
expect(yield* Deferred.isDone(secondInterrupted)).toBeFalse()
yield* Deferred.succeed(secondGate, undefined)
yield* coordinator.awaitIdle("second")
}),
),
)
it.effect("interrupts the active drain and suppresses its queued wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
)
: Effect.void,
),
),
})
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session")
yield* coordinator.interrupt("session")
yield* Deferred.await(interrupted)
yield* coordinator.awaitIdle("session")
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(runs).toBe(1)
yield* coordinator.interrupt("session")
}),
),
)
it.effect("suppresses a wake received during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", 1)
yield* Deferred.await(firstInterrupted)
expect(runs).toBe(1)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
yield* coordinator.wake("session", 3)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("remembers a wake received after the interrupt boundary during cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Deferred.await(firstInterrupted)
yield* coordinator.wake("session", 3)
const staleInterrupt = yield* coordinator.interrupt("session", 1).pipe(Effect.forkChild)
expect(runs).toBe(1)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Fiber.join(staleInterrupt)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("moves the stop barrier forward for repeated interrupts", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const firstInterrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Deferred.await(firstInterrupted)
yield* coordinator.wake("session", 3)
const secondInterrupt = yield* coordinator.interrupt("session", 4).pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(firstInterrupt)
yield* Fiber.join(secondInterrupt)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
yield* coordinator.wake("session", 5)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts an explicit run queued before the interruption request", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Effect.void,
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.interrupt("session")
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(runs).toBe(1)
}),
),
)
it.effect("settles a pre-interrupt explicit run only after active wake cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
const run = yield* coordinator
.run("session")
.pipe(Effect.exit, Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
expect(yield* Deferred.isDone(runSettled)).toBeFalse()
yield* Deferred.succeed(cleanupGate, undefined)
const runExit = yield* Fiber.join(run)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
}),
),
)
it.effect("starts an explicit run arriving during interrupt cleanup after the stop barrier", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Fiber.join(run)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts pre-stop waiters and runs post-stop waiters after cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const before = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const after = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
const beforeExit = yield* Fiber.join(before)
expect(Exit.isFailure(beforeExit) && Cause.hasInterruptsOnly(beforeExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
yield* Fiber.join(after)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("waits for interrupt cleanup before settling callers", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const idleSettled = yield* Deferred.make<void>()
const interruptSettled = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
const run = yield* coordinator
.run("session")
.pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
yield* Deferred.await(started)
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.exit, Effect.ensuring(Deferred.succeed(idleSettled, undefined)), Effect.forkChild)
const interrupt = yield* coordinator
.interrupt("session")
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
yield* Deferred.await(cleanupStarted)
expect(yield* Deferred.isDone(runSettled)).toBeFalse()
expect(yield* Deferred.isDone(idleSettled)).toBeFalse()
expect(yield* Deferred.isDone(interruptSettled)).toBeFalse()
yield* Deferred.succeed(cleanupGate, undefined)
const runExit = yield* Fiber.await(run)
const idleExit = yield* Fiber.join(idle)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(Exit.isFailure(idleExit) && Cause.hasInterruptsOnly(idleExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
}),
),
)
it.effect("joins concurrent interruption requests for one active drain", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
const first = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const second = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.effect("does not discard a post-stop explicit run when interrupted again", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const firstInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
const secondInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(firstInterrupt), Fiber.join(secondInterrupt), Fiber.join(run)])
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("coalesces wakes received during an active run", () =>
Effect.scoped(
Effect.gen(function* () {
@ -381,4 +911,103 @@ describe("SessionRunCoordinator", () => {
}),
),
)
it.effect("reports an advisory drain failure exactly once", () =>
Effect.scoped(
Effect.gen(function* () {
const failure = new Error("wake failed")
const reported: Cause.Cause<Error>[] = []
const reportedOnce = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: () => Effect.fail(failure),
onFailure: (_key, cause) =>
Effect.sync(() => reported.push(cause)).pipe(Effect.andThen(Deferred.succeed(reportedOnce, undefined))),
})
yield* coordinator.wake("session")
yield* Deferred.await(reportedOnce)
yield* Effect.yieldNow
expect(reported).toHaveLength(1)
expect(Cause.squash(reported[0]!)).toBe(failure)
}),
),
)
it.effect("contains defects thrown while constructing an advisory failure report", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: () => Effect.fail(new Error("wake failed")),
onFailure: () => {
throw new Error("report defect")
},
})
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
}),
),
)
it.effect("reports an independently interrupted advisory drain", () =>
Effect.scoped(
Effect.gen(function* () {
const reported = yield* Deferred.make<Cause.Cause<never>>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () => Effect.interrupt,
onFailure: (_key, cause) => Deferred.succeed(reported, cause).pipe(Effect.asVoid),
})
yield* coordinator.wake("session")
expect(Cause.hasInterruptsOnly(yield* Deferred.await(reported))).toBeTrue()
}),
),
)
it.effect("does not report deliberate interruption as an advisory failure", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const reported: Cause.Cause<never>[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
onFailure: (_key, cause) => Effect.sync(() => reported.push(cause)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session")
yield* Effect.yieldNow
expect(reported).toEqual([])
}),
),
)
it.effect("trampolines many synchronous self-waking drains", () =>
Effect.scoped(
Effect.gen(function* () {
const limit = 20_000
let runs = 0
let wake: (key: string) => Effect.Effect<void> = () => Effect.void
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (key) =>
Effect.sync(() => ++runs).pipe(
Effect.tap((run) => (run < limit ? wake(key) : Effect.void)),
Effect.asVoid,
),
})
wake = coordinator.wake
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session")
expect(runs).toBe(limit)
}),
),
)
})

View file

@ -20,6 +20,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
@ -61,6 +62,7 @@ const model = OpenAIChat.route
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = SystemContextRegistry.layer
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
@ -70,6 +72,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
)
@ -77,7 +80,9 @@ const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
SessionExecution.Service,
SessionRunCoordinator.Service.pipe(
Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
Effect.map((coordinator) =>
SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake, interrupt: coordinator.interrupt }),
),
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
@ -100,6 +105,7 @@ const it = testEffect(
registry,
models,
systemContext,
location,
skillGuidance,
runner,
coordinator,

View file

@ -46,6 +46,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
@ -187,6 +188,7 @@ const systemContext = Layer.effectDiscard(
),
),
).pipe(Layer.provideMerge(SystemContextRegistry.layer))
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const skillGuidance = Layer.mock(SkillGuidance.Service, {
load: (agent) =>
Effect.succeed(
@ -210,6 +212,7 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
)
@ -217,7 +220,9 @@ const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
SessionExecution.Service,
SessionRunCoordinator.Service.pipe(
Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
Effect.map((coordinator) =>
SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake, interrupt: coordinator.interrupt }),
),
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
@ -242,6 +247,7 @@ const it = testEffect(
echo,
models,
systemContext,
location,
skillGuidance,
runner,
coordinator,
@ -624,7 +630,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("requires a complete new baseline after a Session moves", () =>
it.effect("interrupts a source Location runner after a Session moves", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@ -648,12 +654,10 @@ describe("SessionRunnerLLM", () => {
.get(),
).toBeUndefined()
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
}),
@ -1986,6 +1990,92 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves durable queued input for a later wake after interruption", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
[],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run after interrupt" }),
delivery: "queue",
})
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInput.hasPending(db, sessionID, "queue")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 2) yield* Effect.yieldNow
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(resumed)
streamGate = undefined
streamStarted = undefined
expect(requests).toHaveLength(2)
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Run after interrupt"])
}),
)
it.effect("preserves durable steering input for a later resume after interruption", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
[],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Steer after interrupt" }),
})
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 2) yield* Effect.yieldNow
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(resumed)
streamGate = undefined
streamStarted = undefined
expect(requests).toHaveLength(2)
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Steer after interrupt"])
}),
)
it.effect("runs queued active inputs as separate FIFO activities", () =>
Effect.gen(function* () {
yield* setup
@ -2697,13 +2787,13 @@ describe("SessionRunnerLLM", () => {
Stream.never,
)
const runner = yield* SessionRunner.Service
const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (executions.length === 0) yield* Effect.yieldNow
yield* Fiber.interrupt(run)
yield* session.interrupt(sessionID)
toolExecutionGate = undefined
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
yield* session.interrupt(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Interrupt blocked tool" },
{
@ -2732,6 +2822,29 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("interrupts a blocked provider turn without local tool activity", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt provider" }), resume: false })
requests.length = 0
response = []
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.interrupt(sessionID)
const exit = yield* Fiber.await(run)
streamGate = undefined
streamStarted = undefined
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(requests).toHaveLength(1)
yield* session.interrupt(sessionID)
}),
)
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
Effect.gen(function* () {
yield* setup