Files
fusion/packages/dashboard/src/ai-session-timeout.ts
gsxdsm 60d0849154 fix(dashboard,core): bound AI session prompt() with timeout + abort
Subtask, mission-interview, and milestone/slice-interview sessions could pin
their `generating` state forever when the underlying provider stream stalled
silently or a tool call hung. Wrap each `agent.session.prompt()` in a new
GenerationGuard helper (per-session AbortController + timer) so a stuck turn
becomes a bounded error users can retry. Adds matching `stop*Generation`
exports and threads abort through cleanup so dismissing a modal cancels the
in-flight call instead of leaking it.

Also closes the gh-cli tool hang vector: `runGhAsync` / `runGhJsonAsync` now
accept `{ signal, timeoutMs }` (default 30s). Github-touching extension tools
forward the AI tool's signal so an aborted agent kills the `gh` child instead
of orphaning it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:25:55 -07:00

139 lines
4.1 KiB
TypeScript

/**
* Per-session generation guard: bounds the runtime of an in-flight AI
* `prompt()` call with a timeout and an `AbortController`, so a silently-
* stalled model stream or hung tool call cannot leave a session pinned in
* `generating` forever.
*
* The guard is module-scoped: planning, subtask-breakdown, mission-interview,
* and milestone-slice-interview each instantiate their own. The session ID is
* the key, so concurrent generations across modules don't collide.
*/
export interface TimeoutHandlers {
/** Fired exactly once when the timeout elapses, before the abort propagates. */
onTimeout: () => void;
/** Fired when abort happens for a non-timeout reason (e.g. manual stop). */
onUserStop?: () => void;
}
interface ActiveEntry {
abort: AbortController;
timer: ReturnType<typeof setTimeout>;
}
type AbortCause = "timeout" | "user-stop" | "displaced";
export class GenerationGuard {
private readonly active = new Map<string, ActiveEntry>();
/**
* Tracks the cause of a pending abort so the original `run()` can
* distinguish a user-initiated stop from re-entrant displacement. The flag
* is consumed (deleted) by the catch block of the displaced `run()`.
*/
private readonly abortCause = new Map<AbortController, AbortCause>();
/**
* Wrap `op` with a timeout + abort. If a previous generation is still
* registered for the same id, it is aborted first (the prior `run()`
* rejects with `AbortError`, marked as `displaced` so its `onUserStop`
* handler does NOT fire).
*/
async run<T>(
sessionId: string,
timeoutMs: number,
handlers: TimeoutHandlers,
op: () => Promise<T>,
): Promise<T> {
this.cancelInternal(sessionId, "displaced");
const abort = new AbortController();
const timer = setTimeout(() => {
this.abortCause.set(abort, "timeout");
try {
handlers.onTimeout();
} catch {
// swallow — handler errors must not prevent abort
}
abort.abort();
}, timeoutMs);
const entry: ActiveEntry = { abort, timer };
this.active.set(sessionId, entry);
const abortPromise = new Promise<never>((_, reject) => {
abort.signal.addEventListener(
"abort",
() => reject(createAbortError()),
{ once: true },
);
});
try {
return await Promise.race([op(), abortPromise]);
} catch (err) {
if (isAbortError(err)) {
const cause = this.abortCause.get(abort) ?? "user-stop";
if (cause === "user-stop") {
try {
handlers.onUserStop?.();
} catch {
// swallow
}
}
}
throw err;
} finally {
clearTimeout(timer);
this.abortCause.delete(abort);
if (this.active.get(sessionId) === entry) {
this.active.delete(sessionId);
}
}
}
/** AbortSignal of the in-flight generation, if any — for tools that honor it. */
signal(sessionId: string): AbortSignal | undefined {
return this.active.get(sessionId)?.abort.signal;
}
has(sessionId: string): boolean {
return this.active.has(sessionId);
}
/**
* Manually abort the active generation. Returns true if there was one.
* The wrapped operation will reject with `AbortError`, and the `onUserStop`
* handler from the original `run()` call will fire.
*/
stop(sessionId: string): boolean {
return this.cancelInternal(sessionId, "user-stop");
}
/** Reset all in-flight generations (test/shutdown only). */
reset(): void {
for (const sessionId of [...this.active.keys()]) {
this.cancelInternal(sessionId, "user-stop");
}
}
private cancelInternal(sessionId: string, cause: AbortCause): boolean {
const entry = this.active.get(sessionId);
if (!entry) return false;
clearTimeout(entry.timer);
this.abortCause.set(entry.abort, cause);
entry.abort.abort();
this.active.delete(sessionId);
return true;
}
}
export function createAbortError(): Error {
const error = new Error("Generation aborted");
error.name = "AbortError";
return error;
}
export function isAbortError(err: unknown): boolean {
return err instanceof Error && err.name === "AbortError";
}