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

@@ -31,7 +31,9 @@ import {
SessionNotFoundError,
InvalidSessionStateError,
setAiSessionStore,
stopSubtaskGeneration,
SubtaskStreamManager,
GENERATION_TIMEOUT_MS,
} from "../subtask-breakdown.js";
const UUID_REGEX =
@@ -913,3 +915,85 @@ describe("SessionNotFoundError", () => {
expect(error.message).toBe("Missing session");
});
});
describe("subtask generation timeout / abort", () => {
it("marks the session as error and stops the prompt() promise when generation exceeds GENERATION_TIMEOUT_MS", async () => {
vi.useFakeTimers();
let resolveHungPrompt: (() => void) | undefined;
const hungPromptCallable = vi.fn(async () => {
// Simulate a stalled provider stream that never terminates on its own.
await new Promise<void>((resolve) => { resolveHungPrompt = resolve; });
return undefined;
});
mockCreateFnAgent.mockImplementation(async () => ({
session: {
state: { messages: [] },
prompt: hungPromptCallable,
dispose: vi.fn(),
},
}));
const created = await createSubtaskSession(
"Hung subtask generation",
undefined,
"/tmp/project",
);
// Yield once so startSubtaskGeneration's microtasks run before we advance time.
await Promise.resolve();
expect(getSubtaskSession(created.sessionId)?.status).toBe("generating");
await vi.advanceTimersByTimeAsync(GENERATION_TIMEOUT_MS);
// Drain any remaining microtasks (the abort propagates through Promise.race).
await vi.advanceTimersByTimeAsync(0);
const after = getSubtaskSession(created.sessionId);
expect(after?.status).toBe("error");
expect(after?.error).toMatch(/timed out/i);
// The hung prompt is still pending; release it so its microtask completes.
resolveHungPrompt?.();
await vi.advanceTimersByTimeAsync(0);
vi.useRealTimers();
});
it("stopSubtaskGeneration aborts an in-flight session and marks it stopped", async () => {
vi.useFakeTimers();
let resolveHungPrompt: (() => void) | undefined;
mockCreateFnAgent.mockImplementation(async () => ({
session: {
state: { messages: [] },
prompt: vi.fn(async () => {
await new Promise<void>((resolve) => { resolveHungPrompt = resolve; });
}),
dispose: vi.fn(),
},
}));
const created = await createSubtaskSession(
"Stoppable subtask generation",
undefined,
"/tmp/project",
);
await Promise.resolve();
expect(stopSubtaskGeneration(created.sessionId)).toBe(true);
await vi.advanceTimersByTimeAsync(0);
const after = getSubtaskSession(created.sessionId);
expect(after?.status).toBe("error");
expect(after?.error).toMatch(/stopped by user/i);
// Stop is idempotent — no in-flight generation after first call.
expect(stopSubtaskGeneration(created.sessionId)).toBe(false);
resolveHungPrompt?.();
await vi.advanceTimersByTimeAsync(0);
vi.useRealTimers();
});
});