diff --git a/src/main/daemon/post-ready-flush-gate.test.ts b/src/main/daemon/post-ready-flush-gate.test.ts index 6614f3fe79b..49379a53bad 100644 --- a/src/main/daemon/post-ready-flush-gate.test.ts +++ b/src/main/daemon/post-ready-flush-gate.test.ts @@ -83,4 +83,15 @@ describe('PostReadyFlushGate', () => { vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS * 2) expect(onFlush).not.toHaveBeenCalled() }) + + it('isPending is true throughout the gate window and false once flush fires', () => { + expect(gate.isPending).toBe(false) + gate.arm() + expect(gate.isPending).toBe(true) + gate.notifyData() + expect(gate.isPending).toBe(true) + vi.advanceTimersByTime(POST_READY_FLUSH_DELAY_MS) + expect(gate.isPending).toBe(false) + expect(onFlush).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/daemon/post-ready-flush-gate.ts b/src/main/daemon/post-ready-flush-gate.ts index a78442bdf2b..2e15b184068 100644 --- a/src/main/daemon/post-ready-flush-gate.ts +++ b/src/main/daemon/post-ready-flush-gate.ts @@ -28,6 +28,12 @@ export class PostReadyFlushGate { constructor(private readonly onFlush: () => void) {} + /** True between arm() and the actual flush firing. Callers should treat + * input as still-queued during this window to preserve ordering. */ + get isPending(): boolean { + return this.awaitingPromptDraw || this.postDataTimer !== null || this.fallbackTimer !== null + } + /** Arm the gate after observing the shell-ready marker. Starts the * wall-clock fallback; the flush fires when the fallback elapses or when * notifyData() observes a subsequent PTY data chunk. */ diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 1fc7307778b..5191f77fd6b 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -178,29 +178,25 @@ describe('Session', () => { }) describe('shell readiness gating', () => { - it('buffers writes during pending state', () => { - createSession({ shellReadySupported: true }) - expect(session.shellState).toBe('pending') - - session.write('buffered input') - expect(subprocess.written).toEqual([]) - }) - // Why: regression guard for "claude claude" double-echo. The marker fires // from precmd before readline switches the PTY into raw mode; flushing // then lets the kernel re-echo the command under the prompt. Detailed - // timing behavior is covered by post-ready-flush-gate.test.ts; here we - // just verify Session wires the gate in (no write on the raw marker). - it('defers flush past the shell-ready marker to avoid kernel ECHO duplication', () => { + // timing behavior is covered by post-ready-flush-gate.test.ts. + // Also checks writes that arrive during the gate window keep their order + // — the gate continues to queue even though shellState is already 'ready'. + it('defers flush past the shell-ready marker and preserves write order', () => { createSession({ shellReadySupported: true }) - session.write('claude\n') + expect(session.shellState).toBe('pending') + + session.write('first\n') subprocess.simulateData('\x1b]777;orca-shell-ready\x07') expect(session.shellState).toBe('ready' satisfies ShellReadyState) + session.write('second\n') expect(subprocess.written).toEqual([]) subprocess.simulateData('\r\nuser@host $ ') vi.advanceTimersByTime(30) - expect(subprocess.written).toEqual(['claude\n']) + expect(subprocess.written).toEqual(['first\n', 'second\n']) }) it('transitions to timed_out after 15 seconds', () => { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 7ed9bf17019..4c398f5a2e0 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -106,7 +106,11 @@ export class Session { return } - if (this._shellState === 'pending') { + // Why: during the post-ready flush gate window (shellState is already + // 'ready' but the queue hasn't flushed yet) we must keep queuing. Writing + // directly would let fresh input race ahead of the buffered startup + // command, changing execution order. + if (this._shellState === 'pending' || this.postReadyFlushGate.isPending) { this.preReadyStdinQueue.push(data) return }