-
Notifications
You must be signed in to change notification settings - Fork 1
Handle headless signals as graceful cancellation #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cfd7d8d
f3d4411
b848c8c
e97241e
ab01670
0d38fe9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,14 @@ | ||
| export const SCHEMA_VERSION = "1" | ||
|
|
||
| export type SessionErrorReason = "rate_limit" | "auth" | "timeout" | "oom" | "provider" | "unknown" | ||
| export type SessionErrorReason = | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 New SessionErrorReason values not propagated to SDK/plugin. 🤖 Fix with your agentWhy this matters
export const SCHEMA_VERSION = "1"
export type SessionErrorReason =
| "rate_limit"
| "auth"
| "timeout"
| "oom"
| "provider"
| "interrupted"
| "terminated"
| "unknown" |
||
| | "rate_limit" | ||
| | "auth" | ||
| | "timeout" | ||
| | "oom" | ||
| | "provider" | ||
| | "interrupted" | ||
| | "terminated" | ||
| | "unknown" | ||
|
|
||
| export type ClassifiedSessionError = { | ||
| reason: SessionErrorReason | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| type Emit = (type: string, data: Record<string, unknown>) => unknown | ||
|
|
||
| export function terminal(emit: Emit) { | ||
| const state = { | ||
| complete: false, | ||
| error: false, | ||
| } | ||
|
|
||
| return { | ||
| complete(data: Record<string, unknown>) { | ||
| if (state.complete) return | ||
| state.complete = true | ||
| emit("session_complete", data) | ||
| }, | ||
| error(data: Record<string, unknown>) { | ||
| if (state.complete || state.error) return | ||
| state.error = true | ||
| emit("session_error", data) | ||
| }, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { pathToFileURL } from "bun" | |||||
| import { UI } from "../ui" | ||||||
| import { cmd } from "./cmd" | ||||||
| import { classifySessionError, SCHEMA_VERSION } from "./run.errors" | ||||||
| import { terminal } from "./run.terminal" | ||||||
| import { buildToolCatalogItems } from "./tool-catalog" | ||||||
| import { withTimeout } from "../../util/timeout" | ||||||
| import { Flag } from "../../flag/flag" | ||||||
|
|
@@ -37,7 +38,10 @@ import { SkillTool } from "../../tool/skill" | |||||
| import { BashTool } from "../../tool/bash" | ||||||
| import { TodoWriteTool } from "../../tool/todo" | ||||||
| import { Locale } from "../../util/locale" | ||||||
| import { Log } from "../../util/log" | ||||||
| import { Shutdown } from "../shutdown" | ||||||
| import { Stdout } from "../stdout" | ||||||
| import { attempt, signals, type Signals } from "../signals" | ||||||
| import { createRunInvocation } from "./run.invocation" | ||||||
|
|
||||||
| type ToolProps<T extends Tool.Info> = { | ||||||
|
|
@@ -534,11 +538,6 @@ export const RunCommand = cmd({ | |||||
|
|
||||||
| const events = await sdk.event.subscribe() | ||||||
| let error: string | undefined | ||||||
| // Guard: at most one session_error is emitted per run regardless of which | ||||||
| // failure path fires first (in-loop session.error handler, loopDone.catch, | ||||||
| // or promptResult rejection). Without this a mid-run session.error event | ||||||
| // can fire site 1 while promptResult/loop() also rejects and fires site 2/3. | ||||||
| let sessionErrorEmitted = false | ||||||
| const startTime = Date.now() | ||||||
| const childSessions = new Set<string>() | ||||||
| const emitted = new Set<string>() | ||||||
|
|
@@ -549,6 +548,20 @@ export const RunCommand = cmd({ | |||||
| return n | ||||||
| } | ||||||
|
|
||||||
| // Keep every failure path on one ordered, idempotent terminal lifecycle. | ||||||
| const output = terminal(emit) | ||||||
|
|
||||||
| function complete(message = error ?? null) { | ||||||
| output.complete({ | ||||||
| durationMs: Date.now() - startTime, | ||||||
| error: message, | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 report() can emit session_error after session_complete. --- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -546,7 +546,7 @@
function report(reason: string, code: string | undefined, message: string) {
- if (sessionErrorEmitted) return
+ if (sessionCompleteEmitted || sessionErrorEmitted) return
sessionErrorEmitted = true
emit("session_error", { reason, code, message })
}🤖 Fix with your agentWhy this matters
function complete(message = error ?? null) {
if (sessionCompleteEmitted) return
sessionCompleteEmitted = true
emit("session_complete", {
durationMs: Date.now() - startTime,
error: message,
})
}
function report(reason: string, code: string | undefined, message: string) {
if (sessionErrorEmitted) return
sessionErrorEmitted = true
emit("session_error", { reason, code, message })
} |
||||||
| function report(reason: string, code: string | undefined, message: string) { | ||||||
| output.error({ reason, code, message }) | ||||||
| } | ||||||
|
|
||||||
| async function loop() { | ||||||
| const toggles = new Map<string, boolean>() | ||||||
|
|
||||||
|
|
@@ -695,22 +708,13 @@ export const RunCommand = cmd({ | |||||
| // to a non-zero exit code so CI wrappers see the failure instead of a | ||||||
| // spuriously-green job. process.exitCode (not process.exit) lets the | ||||||
| // loop drain to session.status idle and emit session_complete first. | ||||||
| process.exitCode = 1 | ||||||
| if (!control.current) process.exitCode = 1 | ||||||
| invocation.error(props.error) | ||||||
| if (!sessionErrorEmitted) { | ||||||
| sessionErrorEmitted = true | ||||||
| const classified = classifySessionError(props.error) | ||||||
| // Structured session_error (classified reason/code/message) is emitted for the | ||||||
| // primary session only. The generic "error" event below fires for both primary | ||||||
| // and child-session failures and carries the raw error object — these are | ||||||
| // intentionally distinct channels: session_error is for structured telemetry/CI | ||||||
| // consumers, "error" is the legacy observable for raw error pass-through. | ||||||
| emit("session_error", { | ||||||
| reason: classified.reason, | ||||||
| code: classified.code, | ||||||
| message: classified.message, | ||||||
| }) | ||||||
| } | ||||||
| const classified = classifySessionError(props.error) | ||||||
| // Structured session_error is the telemetry/CI channel for the | ||||||
| // primary session. The legacy "error" event below is the raw | ||||||
| // pass-through for both primary and child-session failures. | ||||||
| report(classified.reason, classified.code, classified.message) | ||||||
| } | ||||||
| if (emit("error", { error: props.error, sourceSessionID: props.sessionID })) continue | ||||||
| UI.error(err) | ||||||
|
|
@@ -845,6 +849,48 @@ export const RunCommand = cmd({ | |||||
| permissions: PermissionNext.merge(agentInfo.permission, rules), | ||||||
| }) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 cancel() misses sync throws from sdk.session.abort. 🤖 Fix with your agentWhy this matters
function cancel() {
Promise.resolve(sdk.session.abort({ sessionID })).catch((e) => {
console.error(e)
})
}
using control = signals((signal) => {There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Raw SDK abort error dumped to stderr via console.error. 🤖 Fix with your agentWhy this mattersThe signal-cancel path catches the rejection from function cancel() {
Promise.resolve(sdk.session.abort({ sessionID })).catch((e) => {
console.error(e)
})
} |
||||||
|
|
||||||
| let aborted = false | ||||||
| function abort() { | ||||||
| if (aborted) return | ||||||
| aborted = true | ||||||
| attempt( | ||||||
| () => sdk.session.abort({ sessionID }), | ||||||
| () => Log.Default.error("session abort failed"), | ||||||
| ) | ||||||
| } | ||||||
|
|
||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 session_complete.error mixes provider and signal text.
Suggested change
🤖 Fix with your agentWhy this mattersWhen a non-fatal provider error already populated using control = signals((signal) => {
error = error ? error + EOL + signal.message : signal.message
if (!sessionErrorEmitted) {
sessionErrorEmitted = true
emit("session_error", {
reason: signal.reason,
code: String(signal.code),
message: signal.message,
})
}
cancel()
}) |
||||||
| function interrupt(signal: Signals.Info) { | ||||||
| error ??= signal.message | ||||||
| invocation.error(signal.message) | ||||||
| report(signal.reason, String(signal.code), signal.message) | ||||||
| abort() | ||||||
| } | ||||||
|
|
||||||
| async function expire(signal: Signals.Info) { | ||||||
| complete(error ?? signal.message) | ||||||
| await invocation.abort(signal.message) | ||||||
| await Shutdown.flush() | ||||||
| } | ||||||
|
|
||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 abort() no-op if signal beats RPC handler wiring.
Suggested change
🤖 Fix with your agentWhy this matters
function abort() {
if (aborted) return
aborted = true
attempt(
() => sdk.session.abort({ sessionID }),
() => Log.Default.error("session abort failed"),
)
} |
||||||
| using control = signals(interrupt, 5_000, expire) | ||||||
|
|
||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 fail() drops real cause when a signal arrived first. 🤖 Fix with your agentWhy this mattersIf a signal lands first, function fail(cause: unknown) {
const classified = classifySessionError(cause)
error ??= classified.message
report(classified.reason, classified.code, classified.message)
complete(error)
if (control.current) return
console.error(cause)
process.exitCode = 1
} |
||||||
| function reject(cause: unknown) { | ||||||
| const classified = classifySessionError(cause) | ||||||
| error ??= classified.message | ||||||
| invocation.error(cause) | ||||||
| report(classified.reason, classified.code, classified.message) | ||||||
| complete(error) | ||||||
| if (control.current) { | ||||||
| Log.Default.error("run failed after signal", { | ||||||
| reason: classified.reason, | ||||||
| code: classified.code, | ||||||
| }) | ||||||
| return | ||||||
| } | ||||||
| console.error(cause) | ||||||
| process.exitCode = 1 | ||||||
| } | ||||||
|
|
||||||
| // Emit the resolved tool catalog (builtin + MCP tools) and available | ||||||
| // skills before the first model turn. Enables structural detection of | ||||||
| // "tool was not exposed" failure modes (issue #85). | ||||||
|
|
@@ -873,31 +919,13 @@ export const RunCommand = cmd({ | |||||
| }) | ||||||
| } | ||||||
|
|
||||||
| const loopDone = loop() | ||||||
| .then(() => { | ||||||
| emit("session_complete", { | ||||||
| durationMs: Date.now() - startTime, | ||||||
| error: error ?? null, | ||||||
| }) | ||||||
| }) | ||||||
| .catch((e) => { | ||||||
| const classified = classifySessionError(e) | ||||||
| if (!sessionErrorEmitted) { | ||||||
| sessionErrorEmitted = true | ||||||
| emit("session_error", { | ||||||
| reason: classified.reason, | ||||||
| code: classified.code, | ||||||
| message: classified.message, | ||||||
| }) | ||||||
| } | ||||||
| emit("session_complete", { | ||||||
| durationMs: Date.now() - startTime, | ||||||
| error: classified.message, | ||||||
| }) | ||||||
| console.error(e) | ||||||
| process.exitCode = 1 | ||||||
| invocation.error(e) | ||||||
| }) | ||||||
| const loopDone = loop().then(() => complete(), reject) | ||||||
|
|
||||||
| if (control.current) { | ||||||
| await loopDone | ||||||
| await Shutdown.flush() | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| if (args.command) { | ||||||
| await sdk.session.command({ | ||||||
|
|
@@ -924,33 +952,8 @@ export const RunCommand = cmd({ | |||||
| // (e.g., Session.get() fails, model not found), no session.status idle event | ||||||
| // is emitted, so loopDone would hang forever. Racing ensures we surface | ||||||
| // the error and exit. | ||||||
| await Promise.race([ | ||||||
| loopDone, | ||||||
| promptResult.then( | ||||||
| // If prompt resolves normally, wait for the event loop to finish | ||||||
| () => loopDone, | ||||||
| // If prompt rejects, surface the error immediately | ||||||
| (e) => { | ||||||
| const classified = classifySessionError(e) | ||||||
| error = error ? error + EOL + classified.message : classified.message | ||||||
| if (!sessionErrorEmitted) { | ||||||
| sessionErrorEmitted = true | ||||||
| emit("session_error", { | ||||||
| reason: classified.reason, | ||||||
| code: classified.code, | ||||||
| message: classified.message, | ||||||
| }) | ||||||
| } | ||||||
| emit("session_complete", { | ||||||
| durationMs: Date.now() - startTime, | ||||||
| error: classified.message, | ||||||
| }) | ||||||
| console.error(e) | ||||||
| process.exitCode = 1 | ||||||
| invocation.error(e) | ||||||
| }, | ||||||
| ), | ||||||
| ]) | ||||||
| await Promise.race([loopDone, promptResult.then(() => loopDone, reject)]) | ||||||
| await Shutdown.flush() | ||||||
| } | ||||||
|
|
||||||
| invocation.phase("bootstrap") | ||||||
|
|
@@ -983,6 +986,9 @@ export const RunCommand = cmd({ | |||||
| const share = await Session.share(opts.sessionID) | ||||||
| return { data: { share } } | ||||||
| }, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 abort RPC handler forwards unvalidated opts.sessionID. 🤖 Fix with your agentWhy this mattersThe new share(opts: any) {
const share = await Session.share(opts.sessionID)
return { data: { share } }
},
abort(opts: any) {
return SessionPrompt.cancel(opts.sessionID)
},
async prompt(opts: any) { |
||||||
| abort(opts: any) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 abort shim drops SessionPrompt.cancel returned promise. --- a/packages/cli/src/cli/cmd/run.ts
+++ b/packages/cli/src/cli/cmd/run.ts
@@ -955,6 +955,7 @@
return { data: { share } }
},
abort(opts: any) {
- SessionPrompt.cancel(opts.sessionID)
+ return SessionPrompt.cancel(opts.sessionID)
},
🤖 Fix with your agentWhy this mattersThe added shim const share = await Session.share(opts.sessionID)
return { data: { share } }
},
abort(opts: any) {
SessionPrompt.cancel(opts.sessionID)
},
async prompt(opts: any) { |
||||||
| return SessionPrompt.cancel(opts.sessionID) | ||||||
| }, | ||||||
| async prompt(opts: any) { | ||||||
| promptResult = SessionPrompt.prompt({ | ||||||
| sessionID: opts.sessionID, | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,83 @@ | ||||||
| export namespace Signals { | ||||||
| export type Info = { | ||||||
| name: "SIGINT" | "SIGTERM" | ||||||
| reason: "interrupted" | "terminated" | ||||||
| code: 130 | 143 | ||||||
| message: string | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| const info = { | ||||||
| SIGINT: { | ||||||
| name: "SIGINT", | ||||||
| reason: "interrupted", | ||||||
| code: 130, | ||||||
| message: "Session interrupted by SIGINT", | ||||||
| }, | ||||||
| SIGTERM: { | ||||||
| name: "SIGTERM", | ||||||
| reason: "terminated", | ||||||
| code: 143, | ||||||
| message: "Session terminated by SIGTERM", | ||||||
| }, | ||||||
| } as const satisfies Record<Signals.Info["name"], Signals.Info> | ||||||
|
|
||||||
| export function attempt(run: () => unknown, fail: () => void) { | ||||||
| Promise.resolve().then(run).catch(fail) | ||||||
| } | ||||||
|
|
||||||
| export function signals(stop: (info: Signals.Info) => void, grace = 5_000, expire?: (info: Signals.Info) => unknown) { | ||||||
| const state: { | ||||||
| current?: Signals.Info | ||||||
| timer?: ReturnType<typeof setTimeout> | ||||||
| hard?: ReturnType<typeof setTimeout> | ||||||
| resolve?: (info: Signals.Info) => void | ||||||
| } = {} | ||||||
| const received = new Promise<Signals.Info>((resolve) => { | ||||||
| state.resolve = resolve | ||||||
| }) | ||||||
|
|
||||||
| function handle(name: Signals.Info["name"]) { | ||||||
| const signal = info[name] | ||||||
| if (state.current) { | ||||||
| process.exit(state.current.code) | ||||||
| } | ||||||
| state.current = signal | ||||||
| process.exitCode = signal.code | ||||||
| state.timer = setTimeout(() => { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 expire continuation can observe advanced state.current.
Suggested change
🤖 Fix with your agentWhy this mattersIn |
||||||
| state.hard = setTimeout(() => process.exit(signal.code), 250) | ||||||
| Promise.resolve() | ||||||
| .then(() => expire?.(signal)) | ||||||
| .then( | ||||||
| () => { | ||||||
| if (state.hard) clearTimeout(state.hard) | ||||||
| process.exit(signal.code) | ||||||
| }, | ||||||
| () => process.exit(signal.code), | ||||||
| ) | ||||||
| }, grace) | ||||||
| stop(signal) | ||||||
| state.resolve?.(signal) | ||||||
| } | ||||||
|
|
||||||
| const sigint = () => handle("SIGINT") | ||||||
| const sigterm = () => handle("SIGTERM") | ||||||
| process.on("SIGINT", sigint) | ||||||
| process.on("SIGTERM", sigterm) | ||||||
|
|
||||||
| const dispose = () => { | ||||||
| if (state.timer) clearTimeout(state.timer) | ||||||
| if (state.hard) clearTimeout(state.hard) | ||||||
| process.off("SIGINT", sigint) | ||||||
| process.off("SIGTERM", sigterm) | ||||||
| } | ||||||
|
|
||||||
| return { | ||||||
| received, | ||||||
| get current() { | ||||||
| return state.current | ||||||
| }, | ||||||
| dispose, | ||||||
| [Symbol.dispose]: dispose, | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 New enum values added to required field without version bump.
🤖 Fix with your agent
Why this matters
The PR adds two new values (
interrupted,terminated) to the REQUIREDsession_error.reasonfield and broadens thecodefield's semantics, yetsession_start.schemaVersionstays"1". The pre-PR v1 contract told consumers to treat unknown FIELDS as forward-compatible; this PR retroactively extends that same version's contract to also cover unknown event types and enum-like string values (line 10). A consumer that pinned to schema"1"under the original fields-only note and exhaustively validatesreasonagainst the previously-closed enum receives no version signal to re-audit their parser -- the contract changed under the same number. For a REQUIRED field with a previously-closed enum this is borderline-breaking for strict validators, even though the note update is the intended mitigation.