fix(core): fail interrupted session steps

This commit is contained in:
Dax Raad
2026-06-27 01:16:30 -04:00
parent ab0042a666
commit b6553d14e1
3 changed files with 53 additions and 7 deletions
+6 -7
View File
@@ -287,19 +287,18 @@ export const layer = Layer.effect(
} }
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) { if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
return yield* Effect.interrupt return yield* Effect.interrupt
} }
if ( if (streamInterrupted || toolsInterrupted) {
(stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) ||
(settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
if (publisher.hasActiveAssistant()) yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
} }
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause) const failure = Cause.squash(settled.cause)
@@ -307,7 +306,7 @@ export const layer = Layer.effect(
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
} }
const stepSettlement = publisher.stepSettlement() const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !publisher.hasProviderError()) { if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
const endSnapshot = yield* snapshots.capture() const endSnapshot = yield* snapshots.capture()
const files = const files =
startSnapshot && endSnapshot startSnapshot && endSnapshot
+22
View File
@@ -371,6 +371,18 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
const userTexts = (request: LLMRequest) => messageTexts(request, "user") const userTexts = (request: LLMRequest) => messageTexts(request, "user")
const systemTexts = (request: LLMRequest) => messageTexts(request, "system") const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
const recordedEventTypes = (id: SessionV2.ID) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
return yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie, Effect.map((rows) => rows.map((row) => row.type)))
})
const replaySessionProjection = (id: SessionV2.ID) => const replaySessionProjection = (id: SessionV2.ID) =>
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
@@ -2772,6 +2784,11 @@ describe("SessionRunnerLLM", () => {
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(requests).toHaveLength(1) expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Interrupt provider" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Provider turn interrupted" } },
])
expect(yield* recordedEventTypes(sessionID)).toContain("session.next.step.failed.2")
yield* session.interrupt(sessionID) yield* session.interrupt(sessionID)
}), }),
) )
@@ -2801,6 +2818,8 @@ describe("SessionRunnerLLM", () => {
{ type: "user", text: "Interrupt tool settlement" }, { type: "user", text: "Interrupt tool settlement" },
{ {
type: "assistant", type: "assistant",
finish: "error",
error: { type: "unknown", message: "Provider turn interrupted" },
content: [ content: [
{ {
type: "tool", type: "tool",
@@ -2810,6 +2829,9 @@ describe("SessionRunnerLLM", () => {
], ],
}, },
]) ])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("session.next.step.failed.2")
expect(eventTypes).not.toContain("session.next.step.ended.2")
}), }),
) )
+25
View File
@@ -224,6 +224,31 @@ test("tracks session status from active sessions and execution events", async ()
}, },
}) })
await wait(() => data.session.status("session-live") === "idle") await wait(() => data.session.status("session-live") === "idle")
emitEvent(events, {
id: "evt_failed_step_started",
type: "session.next.step.started",
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
timestamp: 3,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.status("session-failed") === "running")
emitEvent(events, {
id: "evt_step_failed",
type: "session.next.step.failed",
data: {
sessionID: "session-failed",
assistantMessageID: "message-failed",
timestamp: 4,
error: { type: "unknown", message: "Provider unavailable" },
},
})
await wait(() => data.session.status("session-failed") === "idle")
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }