Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
52 changes: 52 additions & 0 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ function deepEqual(a: unknown, b: unknown): boolean {
return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[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
Expand Down Expand Up @@ -126,6 +132,8 @@ export class SessionController {
private interruptInFlight: InterruptTransaction | null = null;
private interruptSequence = 0;
private promptDispatchInFlight = false;
private dispatchDeadlineTimer: ReturnType<typeof setTimeout> | null = null;
private dispatchRetried = false;
private turnInterrupted = false;
private prefixNextPromptWithInterruptedMarker = false;
private interruptedOnlyExitCode: number | null = null;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1070,6 +1121,7 @@ export class SessionController {
this.clearInterruptTimer();
this.clearReadinessTimer();
this.clearPermissionResolveTimer();
this.clearDispatchDeadline();

const cleanupPromise = this.ensureCleanupPromise();
void (async () => {
Expand Down