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 596982dd75
commit 2f7ba29ead
11 changed files with 1164 additions and 266 deletions

View File

@@ -96,7 +96,7 @@ function ensureGhCliAuth(): void {
async function fetchGitHubIssuesViaGh(
owner: string,
repo: string,
options: { limit?: number; labels?: string[] } = {},
options: { limit?: number; labels?: string[]; signal?: AbortSignal } = {},
): Promise<GitHubIssueApiResult[]> {
ensureGhCliAuth();
@@ -110,20 +110,25 @@ async function fetchGitHubIssuesViaGh(
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams.toString()}`;
try {
const issues = await runGhJsonAsync<GitHubIssueApiResult[]>(["api", path]);
const issues = await runGhJsonAsync<GitHubIssueApiResult[]>(["api", path], { signal: options.signal });
return issues.filter((issue) => !issue.pull_request);
} catch (error) {
throw new Error(getGhErrorMessage(error));
}
}
async function fetchGitHubIssueViaGh(owner: string, repo: string, issueNumber: number): Promise<GitHubIssueApiResult> {
async function fetchGitHubIssueViaGh(
owner: string,
repo: string,
issueNumber: number,
options: { signal?: AbortSignal } = {},
): Promise<GitHubIssueApiResult> {
ensureGhCliAuth();
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
try {
return await runGhJsonAsync<GitHubIssueApiResult>(["api", path]);
return await runGhJsonAsync<GitHubIssueApiResult>(["api", path], { signal: options.signal });
} catch (error) {
throw new Error(getGhErrorMessage(error));
}
@@ -786,12 +791,12 @@ export default function kbExtension(pi: ExtensionAPI) {
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const [owner, repo] = params.ownerRepo.split("/");
const limit = params.limit ?? 30;
const labels = params.labels;
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
if (issues.length === 0) {
return {
@@ -876,9 +881,9 @@ export default function kbExtension(pi: ExtensionAPI) {
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const { owner, repo, issueNumber } = params;
const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber);
const issue = await fetchGitHubIssueViaGh(owner, repo, issueNumber, { signal });
if (issue.pull_request) {
throw new Error(`#${issueNumber} is a pull request, not an issue`);
@@ -975,9 +980,9 @@ export default function kbExtension(pi: ExtensionAPI) {
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const { owner, repo, limit = 30, labels } = params;
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels });
const issues = await fetchGitHubIssuesViaGh(owner, repo, { limit, labels, signal });
if (issues.length === 0) {
return {