fix(pi-claude-cli): defer to engine StuckTaskDetector instead of 300s subprocess kill

The engine already runs a StuckTaskDetector with a configurable per-task
heartbeat (default 1 hour) and aborts triage/executor sessions through
AbortSignal when it judges them quiet. pi-claude-cli forwards that signal to
the subprocess. The 300s subprocess-level inactivity timeout was a redundant,
much-tighter shadow that kept killing Sonnet 4.6 mid-thinking on the 40k-char
triage prompt — exactly what the engine-level detector is designed not to do.

Move the local timeout to 30 minutes purely as a last-resort guard for embeds
that don't pass an abort signal. Stuck-detection responsibility now lives in
the caller, where it can be configured per task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-25 17:49:09 -07:00
parent 833b9dac61
commit f0e3413b69
2 changed files with 22 additions and 13 deletions

View File

@@ -1292,7 +1292,7 @@ describe("streamViaCli", () => {
}); });
describe("inactivity timeout", () => { describe("inactivity timeout", () => {
it("kills subprocess and pushes error after 300s of no output", async () => { it("kills subprocess and pushes error after 1800s of no output", async () => {
const model = mockModels[0] as any; const model = mockModels[0] as any;
const context = { const context = {
messages: [{ role: "user", content: "Hello" }], messages: [{ role: "user", content: "Hello" }],
@@ -1303,8 +1303,8 @@ describe("streamViaCli", () => {
const proc = (spawn as any).mock.results[0].value; const proc = (spawn as any).mock.results[0].value;
// Advance timers by 300 seconds without writing to stdout // Advance timers by 1800 seconds without writing to stdout
await vi.advanceTimersByTimeAsync(300_000); await vi.advanceTimersByTimeAsync(1_800_000);
const mockStream = MockAssistantMessageEventStream.mock.instances[0]; const mockStream = MockAssistantMessageEventStream.mock.instances[0];
const doneEvent = mockStream._events.find( const doneEvent = mockStream._events.find(
@@ -1331,7 +1331,7 @@ describe("streamViaCli", () => {
const proc = (spawn as any).mock.results[0].value; const proc = (spawn as any).mock.results[0].value;
// Advance to 290s then write a line (just under the 300s cap) // Advance to 290s then write a line (just under the 300s cap)
await vi.advanceTimersByTimeAsync(290_000); await vi.advanceTimersByTimeAsync(1_790_000);
// Write a stream event line // Write a stream event line
proc.stdout.write( proc.stdout.write(
@@ -1346,7 +1346,7 @@ describe("streamViaCli", () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
// Advance another 290s (580s total, 290s since last line) -- should NOT timeout // Advance another 290s (580s total, 290s since last line) -- should NOT timeout
await vi.advanceTimersByTimeAsync(290_000); await vi.advanceTimersByTimeAsync(1_790_000);
const mockStream = MockAssistantMessageEventStream.mock.instances[0]; const mockStream = MockAssistantMessageEventStream.mock.instances[0];
const doneEvent = mockStream._events.find( const doneEvent = mockStream._events.find(
@@ -1354,7 +1354,7 @@ describe("streamViaCli", () => {
); );
expect(doneEvent).toBeUndefined(); expect(doneEvent).toBeUndefined();
// Advance 10 more seconds (300s since last line) -- NOW should timeout // Advance 10 more seconds (1800s since last line) -- NOW should timeout
await vi.advanceTimersByTimeAsync(10_000); await vi.advanceTimersByTimeAsync(10_000);
const doneEvent2 = mockStream._events.find( const doneEvent2 = mockStream._events.find(
@@ -1401,7 +1401,7 @@ describe("streamViaCli", () => {
await vi.advanceTimersByTimeAsync(100); await vi.advanceTimersByTimeAsync(100);
// Advance past 180s -- should NOT timeout since result was received // Advance past 180s -- should NOT timeout since result was received
await vi.advanceTimersByTimeAsync(300_000); await vi.advanceTimersByTimeAsync(1_800_000);
const mockStream = MockAssistantMessageEventStream.mock.instances[0]; const mockStream = MockAssistantMessageEventStream.mock.instances[0];
const errorEvents = mockStream._events.filter( const errorEvents = mockStream._events.filter(

View File

@@ -45,13 +45,22 @@ import { handleControlRequest } from "./control-handler.js";
import { mapThinkingEffort } from "./thinking-config.js"; import { mapThinkingEffort } from "./thinking-config.js";
import { isPiKnownClaudeTool } from "./tool-mapping.js"; import { isPiKnownClaudeTool } from "./tool-mapping.js";
/** /**
* Inactivity timeout: kill the Claude CLI subprocess if no stdout arrives * Inactivity safety net for the Claude CLI subprocess.
* for this long. Sonnet 4.6 with extended thinking on large system prompts *
* (e.g. the triage prompt + AGENTS.md + skill blocks ~ 40k chars) can take * Set very high (30 minutes) because the caller is the authoritative source of
* minutes between thinking deltas; a 3-minute timeout was killing those * truth for "this session is stuck": Fusion's engine runs a `StuckTaskDetector`
* sessions before they could call fn_review_spec. Bump to 5 minutes. * with a configurable heartbeat (default 1 hour) and aborts the session via
* `AbortSignal` when it decides the agent has gone quiet. pi-claude-cli already
* forwards that signal to the subprocess (`forceKillProcess` on `signal.abort`).
*
* A short timeout here was racing the engine: Sonnet 4.6 with extended thinking
* on the triage prompt (~40k chars) routinely goes >3 minutes between thinking
* deltas, and we were killing those subprocesses before they could write
* PROMPT.md and call `fn_review_spec`. The half-hour ceiling is just a
* last-resort guard for catastrophically hung processes when no abort signal
* arrives (e.g. someone embeds pi-claude-cli without a stuck detector).
*/ */
const INACTIVITY_TIMEOUT_MS = 300_000; const INACTIVITY_TIMEOUT_MS = 30 * 60_000;
/** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */ /** Extended stream options: pi's SimpleStreamOptions plus optional cwd and mcpConfigPath */
type StreamViaCLiOptions = SimpleStreamOptions & { type StreamViaCLiOptions = SimpleStreamOptions & {