fix(codex): widen the stale-pane sweep retry ladder past the Windows worst case

Live Windows 11 validation of #10770 measured bind -> notice at 5140ms, 3959ms
and 4064ms across three runs: the third and final rung is what raised the card
every time, with essentially zero margin. macOS resolved on the first rung and
left ~3.3s unused. A slower box, more panes, or a colder daemon reattach pushes
past the last rung, and the sweep then has nothing left to fire — no card, and
the pane silently keeps running the old account, which is the exact bug #10770
exists to fix.

Adds two tail rungs, so the ladder reaches 15.8s and 35.8s from bind instead of
ending at 5.8s. The fast early rungs are untouched, so macOS still resolves in
under a second. Still bounded: five attempts and then the pane is dropped, never
an interval or a poll.
This commit is contained in:
Brennan Benson
2026-07-26 22:17:40 -07:00
parent 74e6478e85
commit a0d8f780be
2 changed files with 40 additions and 8 deletions
@@ -144,16 +144,44 @@ describe('notifyCodexPaneBoundForStaleSweep', () => {
vi.mocked(window.api.pty.inspectProcess).mockRejectedValue(new Error('terminal_gone'))
notifyCodexPaneBoundForStaleSweep('pty-1')
await vi.advanceTimersByTimeAsync(60_000)
// Well past the last rung (35.8s), so this pins the ceiling, not the clock.
await vi.advanceTimersByTimeAsync(120_000)
expect(window.api.pty.inspectProcess).toHaveBeenCalledTimes(3)
expect(window.api.pty.inspectProcess).toHaveBeenCalledTimes(5)
})
it('still sweeps a pane whose reattach outlives the first three rungs', async () => {
// Regression: live Windows 11 runs raised the notice on the third rung
// (5.8s) in all three samples, i.e. the ladder ended exactly where the
// slowest measured box landed. A colder reattach falls past it, and the
// failure is silent — no card, so the pane keeps running the old account.
vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([STALE_PANE])
vi.mocked(window.api.pty.inspectProcess)
.mockRejectedValueOnce(new Error('terminal_gone'))
.mockRejectedValueOnce(new Error('terminal_gone'))
.mockRejectedValueOnce(new Error('terminal_gone'))
notifyCodexPaneBoundForStaleSweep('pty-1')
// t = 5800: rungs 1-3 are spent and nothing has been raised.
await vi.advanceTimersByTimeAsync(5_800)
expect(inspectCallCountFor('pty-1')).toBe(3)
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
// t = 15800: the fourth rung is the one that catches this pane.
await vi.advanceTimersByTimeAsync(10_000)
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
previousAccountLabel: ACCOUNT_A,
nextAccountLabel: ACCOUNT_B
})
expect(inspectCallCountFor('pty-1')).toBe(4)
})
it('spends a pane its full retry ladder even when another pane binds later', async () => {
// Regression: one shared timer used to drain every queued PTY, so a pane
// already waiting on its 1500ms rung had the next rung consumed by the newer
// pane's shorter delay — exhausting its 3 attempts ~2.5s early and dropping
// a Windows daemon reattach that had not settled yet.
// pane's shorter delay — burning through its ladder early and dropping a
// Windows daemon reattach that had not settled yet.
useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-2'] } })
vi.mocked(window.api.pty.inspectProcess).mockRejectedValue(new Error('terminal_gone'))
@@ -5,10 +5,14 @@ import {
// Why: the first delay coalesces the startup burst of binds and lets
// updateTabPtyId (written just after the layout binding) land, since the scan
// walks tabs. The later two cover a reattached daemon shell that answers
// `inspectProcess` with terminal_gone for a beat. Bounded on purpose — this is
// a hint, and a pane that never resolves must not become a polling loop.
const SWEEP_ATTEMPT_DELAYS_MS = [300, 1500, 4000] as const
// walks tabs. The rest cover a reattached daemon shell that answers
// `inspectProcess` with terminal_gone for a beat. The tail is sized off live
// Windows 11 runs, where the 5.8s rung is what raised the notice in all three
// (macOS resolved on the first): the ladder used to end exactly where the
// slowest measured box landed, so a colder reattach fell off it and the pane
// stayed silently stale. Bounded on purpose — this is a hint, and a pane that
// never resolves must not become a polling loop.
const SWEEP_ATTEMPT_DELAYS_MS = [300, 1500, 4000, 10_000, 20_000] as const
// Why: each PTY owns its rung, so the queue has to carry a per-PTY due time. One
// shared timer coalesces the startup burst, but it must always target the