diff --git a/src/session.test.ts b/src/session.test.ts index eba7450..498e1a0 100644 --- a/src/session.test.ts +++ b/src/session.test.ts @@ -1629,3 +1629,92 @@ describe("backend capabilities", () => { expect(summary.title).toBe("From transcript"); }); }); + +// ---- Prompt dispatch deadline ---- + +// A prompt typed into the pty can be silently eaten when Claude's status file +// reports idle a beat before the TUI input box accepts keystrokes. Busy then +// never arrives, and the readiness watchdog is suppressed while a dispatch is +// in flight — previously an unbounded silent wedge (seen live in the parity +// replay). The deadline re-sends once, then fails with a terminal result. +describe("prompt dispatch deadline", () => { + beforeEach(() => { + written = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { + written.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + output.configureOutput({ format: "stream-json", verbose: true, includePartial: true }); + output.resetOutputState(); + vi.useFakeTimers(); + }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); + + const PROMPT = "DISPATCH_DEADLINE_PROBE"; + + function sends(ptyHandle: { writes: string[] }): number { + return ptyHandle.writes.join("").split(PROMPT).length - 1; + } + + async function dispatchProbe() { + const ctx = createTestController({ args: { inputFormat: "stream-json" } }); + await ctx.controller.start(); + ctx.fireStatus("idle"); + ctx.controller.enqueuePrompt(PROMPT); + await vi.advanceTimersByTimeAsync(0); + expect(sends(ctx.ptyHandle)).toBe(1); + return ctx; + } + + it("re-sends the prompt once when no turn starts within the deadline", async () => { + const { fireStatus, ptyHandle } = await dispatchProbe(); + + await vi.advanceTimersByTimeAsync(10_000); + expect(sends(ptyHandle)).toBe(2); + + // The retry takes: turn starts, no error result, no third send. + fireStatus("busy"); + await vi.advanceTimersByTimeAsync(30_000); + expect(sends(ptyHandle)).toBe(2); + expect(parsedLines().filter(l => l.type === "result")).toHaveLength(0); + expect(ptyHandle.kills).toHaveLength(0); + }); + + it("fails with a terminal error result when the retry also produces no turn", async () => { + const { controller, ptyHandle, exitCodes } = await dispatchProbe(); + + await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(10_000); + + const results = parsedLines().filter(l => l.type === "result"); + expect(results).toHaveLength(1); + expect(results[0].subtype).toBe("error"); + expect(ptyHandle.kills).toContain("SIGTERM"); + + controller.handleClaudeExit(143); + await vi.advanceTimersByTimeAsync(0); + expect(exitCodes).toEqual([1]); + // No duplicate result from the exit path. + expect(parsedLines().filter(l => l.type === "result")).toHaveLength(1); + }); + + it("does not re-send when the turn starts promptly", async () => { + const { fireStatus, ptyHandle } = await dispatchProbe(); + + fireStatus("busy"); + await vi.advanceTimersByTimeAsync(30_000); + expect(sends(ptyHandle)).toBe(1); + expect(parsedLines().filter(l => l.type === "result")).toHaveLength(0); + }); + + it("stands down when the prompt produces a permission dialog instead of a turn", async () => { + const { fireStatus, ptyHandle } = await dispatchProbe(); + + // The prompt reached Claude — it opened a dialog. The permission machinery + // owns the session now; re-typing the prompt would corrupt the dialog. + fireStatus("waiting", "approve Bash"); + await vi.advanceTimersByTimeAsync(25_000); + expect(sends(ptyHandle)).toBe(1); + }); +}); diff --git a/src/session.ts b/src/session.ts index 4a0e92e..ca2de49 100644 --- a/src/session.ts +++ b/src/session.ts @@ -43,6 +43,12 @@ function deepEqual(a: unknown, b: unknown): boolean { return ka.every((k) => deepEqual((a as Record)[k], (b as Record)[k])); } const READY_TIMEOUT_MS = 30_000; +// After a prompt is typed into the pty, Claude must start a turn (busy) within +// this window. The TUI's status file reports idle a beat before its input box +// accepts keystrokes, so a prompt typed into that gap is silently eaten — the +// turn never starts and nothing else would ever time out (the readiness +// watchdog is intentionally suppressed while a dispatch is in flight). +const PROMPT_DISPATCH_TIMEOUT_MS = 10_000; // Generous bound for the startup phase: Claude must become observable within // this window or clarp fails fast with a terminal result instead of hanging // forever (the issue-#1 symptom when the session file is never found). Longer @@ -126,6 +132,8 @@ export class SessionController { private interruptInFlight: InterruptTransaction | null = null; private interruptSequence = 0; private promptDispatchInFlight = false; + private dispatchDeadlineTimer: ReturnType | null = null; + private dispatchRetried = false; private turnInterrupted = false; private prefixNextPromptWithInterruptedMarker = false; private interruptedOnlyExitCode: number | null = null; @@ -227,6 +235,7 @@ export class SessionController { if (status === "busy" && !this.turnActive) { this.promptDispatchInFlight = false; + this.clearDispatchDeadline(); this.turnActive = true; this.turnCount++; this.turnStart = Date.now(); @@ -584,6 +593,48 @@ export class SessionController { : item.content; this.prefixNextPromptWithInterruptedMarker = false; sendPrompt(this.opts.ptyHandle, content); + this.dispatchRetried = false; + this.armDispatchDeadline(content); + } + + private armDispatchDeadline(content: string): void { + this.clearDispatchDeadline(); + this.dispatchDeadlineTimer = setTimeout( + () => this.onDispatchDeadline(content), + PROMPT_DISPATCH_TIMEOUT_MS, + ); + } + + private onDispatchDeadline(content: string): void { + this.dispatchDeadlineTimer = null; + // The dispatch resolved (turn started) or the session moved on. + if (!this.promptDispatchInFlight || this.turnActive) return; + if (this.processExited || this.shuttingDown || this.interruptInFlight !== null) return; + // The prompt reached Claude but produced a dialog instead of a turn; the + // permission machinery owns the session now and busy follows its answer. + if (this.waitingForAction || this.pendingPermissionRequestId !== null) return; + + if (!this.dispatchRetried) { + // Recover the input race invisibly: the same prompt, typed again now that + // the TUI is certainly accepting input. + this.dispatchRetried = true; + this.log("Prompt produced no turn within deadline; re-sending once"); + sendPrompt(this.opts.ptyHandle, content); + this.armDispatchDeadline(content); + return; + } + + this.failWithTerminalError( + "Prompt dispatch failed", + `Claude did not start a turn within ${(PROMPT_DISPATCH_TIMEOUT_MS * 2) / 1000}s of the prompt being sent (one retry included). ` + + `The interactive session may be showing a dialog clarp cannot observe.`, + ); + } + + private clearDispatchDeadline(): void { + if (!this.dispatchDeadlineTimer) return; + clearTimeout(this.dispatchDeadlineTimer); + this.dispatchDeadlineTimer = null; } private dispatchSlashCommand(op: SlashCommandOp): void { @@ -1070,6 +1121,7 @@ export class SessionController { this.clearInterruptTimer(); this.clearReadinessTimer(); this.clearPermissionResolveTimer(); + this.clearDispatchDeadline(); const cleanupPromise = this.ensureCleanupPromise(); void (async () => {