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:
@@ -33,6 +33,8 @@ import {
|
||||
skipTargetInterview,
|
||||
MILESTONE_INTERVIEW_SYSTEM_PROMPT,
|
||||
SLICE_INTERVIEW_SYSTEM_PROMPT,
|
||||
stopMilestoneSliceInterviewGeneration,
|
||||
GENERATION_TIMEOUT_MS,
|
||||
type MilestoneInterviewSummary,
|
||||
type SliceInterviewSummary,
|
||||
} from "../milestone-slice-interview.js";
|
||||
@@ -769,4 +771,83 @@ describe("milestone-slice-interview module", () => {
|
||||
expect(SLICE_INTERVIEW_SYSTEM_PROMPT).toContain("acceptanceCriteria");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generation timeout / abort", () => {
|
||||
it("marks the session as error when initial generation exceeds GENERATION_TIMEOUT_MS", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
let resolveHungPrompt: (() => void) | undefined;
|
||||
mockCreateFnAgent.mockImplementationOnce(async () => ({
|
||||
session: {
|
||||
state: { messages: [] },
|
||||
prompt: vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => { resolveHungPrompt = resolve; });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"10.0.1.10",
|
||||
"milestone",
|
||||
"milestone-stuck",
|
||||
"Hung milestone interview",
|
||||
undefined,
|
||||
"/tmp/project",
|
||||
);
|
||||
|
||||
// Yield so the guard registration runs.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(GENERATION_TIMEOUT_MS);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
expect(session?.error).toMatch(/timed out/i);
|
||||
|
||||
resolveHungPrompt?.();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stopMilestoneSliceInterviewGeneration aborts an in-flight session and marks it stopped", async () => {
|
||||
let resolveHungPrompt: (() => void) | undefined;
|
||||
mockCreateFnAgent.mockImplementationOnce(async () => ({
|
||||
session: {
|
||||
state: { messages: [] },
|
||||
prompt: vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => { resolveHungPrompt = resolve; });
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const sessionId = await createTargetInterviewSession(
|
||||
"10.0.1.11",
|
||||
"slice",
|
||||
"slice-stoppable",
|
||||
"Stoppable slice interview",
|
||||
undefined,
|
||||
"/tmp/project",
|
||||
);
|
||||
|
||||
let stopped = false;
|
||||
for (let i = 0; i < 50 && !stopped; i++) {
|
||||
stopped = stopMilestoneSliceInterviewGeneration(sessionId);
|
||||
if (!stopped) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(stopped).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const session = getTargetInterviewSession(sessionId);
|
||||
expect(session?.error).toMatch(/stopped by user/i);
|
||||
expect(stopMilestoneSliceInterviewGeneration(sessionId)).toBe(false);
|
||||
|
||||
resolveHungPrompt?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user