Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 8084f9dfd8 fix(session): distinguish idle reasons for completion and abort 2026-03-10 20:44:34 -04:00
7 changed files with 43 additions and 13 deletions

View File

@ -376,7 +376,7 @@ export const SessionRoutes = lazy(() =>
}), }),
), ),
async (c) => { async (c) => {
SessionPrompt.cancel(c.req.valid("param").sessionID) SessionPrompt.cancel(c.req.valid("param").sessionID, "aborted")
return c.json(true) return c.json(true)
}, },
) )

View File

@ -381,7 +381,10 @@ export namespace SessionProcessor {
sessionID: input.assistantMessage.sessionID, sessionID: input.assistantMessage.sessionID,
error: input.assistantMessage.error, error: input.assistantMessage.error,
}) })
SessionStatus.set(input.sessionID, { type: "idle" }) SessionStatus.set(input.sessionID, {
type: "idle",
reason: error.name === "MessageAbortedError" ? "aborted" : "error",
})
} }
} }
if (snapshot) { if (snapshot) {

View File

@ -254,17 +254,21 @@ export namespace SessionPrompt {
return s[sessionID].abort.signal return s[sessionID].abort.signal
} }
export function cancel(sessionID: string) { export function cancel(sessionID: string, reason: SessionStatus.IdleReason = "aborted") {
log.info("cancel", { sessionID }) log.info("cancel", { sessionID })
const idle = () => {
if (SessionStatus.get(sessionID).type === "idle") return
SessionStatus.set(sessionID, { type: "idle", reason })
}
const s = state() const s = state()
const match = s[sessionID] const match = s[sessionID]
if (!match) { if (!match) {
SessionStatus.set(sessionID, { type: "idle" }) idle()
return return
} }
match.abort.abort() match.abort.abort()
delete s[sessionID] delete s[sessionID]
SessionStatus.set(sessionID, { type: "idle" }) idle()
return return
} }
@ -283,7 +287,8 @@ export namespace SessionPrompt {
}) })
} }
using _ = defer(() => cancel(sessionID)) let reason: SessionStatus.IdleReason = "completed"
using _ = defer(() => cancel(sessionID, reason))
// Structured output state // Structured output state
// Note: On session resumption, state is reset but outputFormat is preserved // Note: On session resumption, state is reset but outputFormat is preserved
@ -295,7 +300,10 @@ export namespace SessionPrompt {
while (true) { while (true) {
SessionStatus.set(sessionID, { type: "busy" }) SessionStatus.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID }) log.info("loop", { step, sessionID })
if (abort.aborted) break if (abort.aborted) {
reason = "aborted"
break
}
let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID))
let lastUser: MessageV2.User | undefined let lastUser: MessageV2.User | undefined
@ -536,7 +544,10 @@ export namespace SessionPrompt {
auto: task.auto, auto: task.auto,
overflow: task.overflow, overflow: task.overflow,
}) })
if (result === "stop") break if (result === "stop") {
reason = abort.aborted ? "aborted" : "completed"
break
}
continue continue
} }
@ -698,11 +709,19 @@ export namespace SessionPrompt {
retries: 0, retries: 0,
}).toObject() }).toObject()
await Session.updateMessage(processor.message) await Session.updateMessage(processor.message)
reason = "error"
break break
} }
} }
if (result === "stop") break if (result === "stop") {
if (processor.message.error?.name === "MessageAbortedError") {
reason = "aborted"
} else if (processor.message.error) {
reason = "error"
}
break
}
if (result === "compact") { if (result === "compact") {
await SessionCompaction.create({ await SessionCompaction.create({
sessionID, sessionID,

View File

@ -4,10 +4,14 @@ import { Instance } from "@/project/instance"
import z from "zod" import z from "zod"
export namespace SessionStatus { export namespace SessionStatus {
export const IdleReason = z.enum(["completed", "aborted", "error"])
export type IdleReason = z.infer<typeof IdleReason>
export const Info = z export const Info = z
.union([ .union([
z.object({ z.object({
type: z.literal("idle"), type: z.literal("idle"),
reason: IdleReason.optional(),
}), }),
z.object({ z.object({
type: z.literal("retry"), type: z.literal("retry"),
@ -65,9 +69,11 @@ export namespace SessionStatus {
}) })
if (status.type === "idle") { if (status.type === "idle") {
// deprecated // deprecated
Bus.publish(Event.Idle, { if (!status.reason || status.reason === "completed") {
sessionID, Bus.publish(Event.Idle, {
}) sessionID,
})
}
delete state()[sessionID] delete state()[sessionID]
return return
} }

View File

@ -119,7 +119,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
function cancel() { function cancel() {
SessionPrompt.cancel(session.id) SessionPrompt.cancel(session.id, "aborted")
} }
ctx.abort.addEventListener("abort", cancel) ctx.abort.addEventListener("abort", cancel)
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))

View File

@ -453,6 +453,7 @@ export type EventPermissionReplied = {
export type SessionStatus = export type SessionStatus =
| { | {
type: "idle" type: "idle"
reason?: "completed" | "aborted" | "error"
} }
| { | {
type: "retry" type: "retry"

View File

@ -581,6 +581,7 @@ export type EventPermissionReplied = {
export type SessionStatus = export type SessionStatus =
| { | {
type: "idle" type: "idle"
reason?: "completed" | "aborted" | "error"
} }
| { | {
type: "retry" type: "retry"