feat(core): interrupt v2 session execution (#30850)
This commit is contained in:
parent
41bd9124f4
commit
12e38866ed
19 changed files with 1047 additions and 112 deletions
|
|
@ -17,6 +17,7 @@ describe("public native OpenCode API", () => {
|
|||
"create",
|
||||
"events",
|
||||
"get",
|
||||
"interrupt",
|
||||
"list",
|
||||
"message",
|
||||
"messages",
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue