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
|
|
@ -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