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>
This commit is contained in:
gsxdsm
2026-04-28 08:05:12 -07:00
parent 54f4fd540e
commit 60d0849154
11 changed files with 1164 additions and 266 deletions

View File

@@ -7,6 +7,29 @@ export interface GhError extends Error {
stdout: string;
}
export interface RunGhOptions {
cwd?: string;
/** External abort signal — propagated to the spawned `gh` process. */
signal?: AbortSignal;
/**
* Hard ceiling on `gh` runtime in milliseconds. The child process is killed
* when exceeded and the returned promise rejects with a `GhError`. Defaults
* to 30_000 (30 s); set to `0` or a negative value to disable.
*
* Without this, an upstream hang (network stall, hung credential helper,
* gh waiting on stdin) can leave the call pending forever — which in turn
* pins any AI session's `prompt()` that triggered the tool call.
*/
timeoutMs?: number;
}
const DEFAULT_GH_TIMEOUT_MS = 30_000;
function normalizeRunGhOptions(opts: string | RunGhOptions | undefined): RunGhOptions {
if (typeof opts === "string") return { cwd: opts };
return opts ?? {};
}
/**
* Check if the `gh` CLI is installed and available.
*/
@@ -63,21 +86,76 @@ export function runGh(args: string[], cwd?: string): string {
/**
* Execute a gh CLI command asynchronously.
* Returns a promise that resolves with the output or rejects with GhError.
*
* Returns a promise that resolves with the output or rejects with `GhError`.
*
* `cwdOrOptions` accepts either a string (cwd, legacy form) or a `RunGhOptions`
* object. The options form supports an external `AbortSignal` and a
* `timeoutMs` ceiling — both default to safe values that prevent indefinite
* hangs when `gh` stalls on the network or a credential helper.
*/
export function runGhAsync(args: string[], cwd?: string): Promise<string> {
export function runGhAsync(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<string> {
const { cwd, signal: externalSignal, timeoutMs = DEFAULT_GH_TIMEOUT_MS } =
normalizeRunGhOptions(cwdOrOptions);
return new Promise((resolve, reject) => {
if (externalSignal?.aborted) {
reject(makeGhError(`gh command aborted: ${describeAbortReason(externalSignal.reason)}`, "ABORT_ERR"));
return;
}
// Compose the abort sources so we can distinguish timeout from external
// abort in the rejection. Using a private controller keeps signal
// ownership inside this function.
const controller = new AbortController();
let timedOut = false;
let externalAborted = false;
const onExternalAbort = () => {
externalAborted = true;
controller.abort();
};
if (externalSignal) {
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
}
const timer = timeoutMs > 0
? setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs)
: undefined;
const cleanup = () => {
if (timer) clearTimeout(timer);
if (externalSignal) externalSignal.removeEventListener("abort", onExternalAbort);
};
execFile(
"gh",
args,
{
encoding: "utf-8",
cwd,
signal: controller.signal,
},
(error, stdout, stderr) => {
cleanup();
if (error) {
const ghError = new Error(`gh command failed: ${error.message}`) as GhError;
ghError.code = error.code ?? null;
const isAbort = (error as ExecFileException & { code?: string | number | null }).code === "ABORT_ERR"
|| error.name === "AbortError";
let message: string;
if (timedOut) {
message = `gh command timed out after ${timeoutMs}ms`;
} else if (isAbort && externalAborted) {
message = `gh command aborted: ${describeAbortReason(externalSignal?.reason)}`;
} else if (isAbort) {
message = "gh command aborted";
} else {
message = `gh command failed: ${error.message}`;
}
const ghError = new Error(message) as GhError;
ghError.code = (error as ExecFileException).code ?? (isAbort ? "ABORT_ERR" : null);
ghError.stdout = stdout ?? "";
ghError.stderr = stderr ?? "";
reject(ghError);
@@ -89,6 +167,20 @@ export function runGhAsync(args: string[], cwd?: string): Promise<string> {
});
}
function makeGhError(message: string, code: string | number | null): GhError {
const err = new Error(message) as GhError;
err.code = code;
err.stdout = "";
err.stderr = "";
return err;
}
function describeAbortReason(reason: unknown): string {
if (reason instanceof Error) return reason.message;
if (typeof reason === "string") return reason;
return "aborted";
}
/**
* Execute a gh CLI command and parse the JSON output.
* Requires the command to support --json flag.
@@ -106,10 +198,13 @@ export function runGhJson<T>(args: string[], cwd?: string): T {
/**
* Execute a gh CLI command asynchronously and parse the JSON output.
* Requires the command to support --json flag.
*
* Forwards `signal` / `timeoutMs` to the underlying `runGhAsync` so callers
* can bound the call from outside (e.g. an AI tool's `signal` argument).
*/
export async function runGhJsonAsync<T>(args: string[], cwd?: string): Promise<T> {
export async function runGhJsonAsync<T>(args: string[], cwdOrOptions?: string | RunGhOptions): Promise<T> {
const jsonArgs = args.includes("--json") ? args : [...args, "--json"];
const output = await runGhAsync(jsonArgs, cwd);
const output = await runGhAsync(jsonArgs, cwdOrOptions);
try {
return JSON.parse(output) as T;
} catch (err) {