Skip to content

fix(trust-dialog): re-read a throttled trust-dialog scan instead of dropping it - #635

Open
JDProfresh wants to merge 1 commit into
Ark0N:masterfrom
JDProfresh:fix/trust-dialog-deferred-read
Open

JDProfresh wants to merge 1 commit into
Ark0N:masterfrom
JDProfresh:fix/trust-dialog-deferred-read

Conversation

@JDProfresh

Copy link
Copy Markdown

The trust-dialog scan must schedule its own next read rather than wait for PTY output (workspace-trust-dialog-auto-accept), but a call inside its 1.5 s throttle was simply dropped. The dialog's own paint is often the last output a launching pane produces and lands inside that throttle, so nothing ever read the screen again and the session sat on the dialog with no keystroke sent. This restores the invariant on the throttled path.

What I first went after did not reproduce. On 2026-10-06 (Claude Code 2.1.280) I saw launches log six moving to the trust option attempts and give up with the pane still on No, exit, and I planned to spread the attempts out. On 2.1.294 that failure did not reproduce in 10 of 10 launches where the scan ran: the first arrow landed every time and the dialog cleared on the second keystroke. What did fail was the scan never firing at all, in 2 of 12 fresh launches. This PR fixes that and leaves the attempt spacing alone.

Problem

On a fresh directory, a Claude session sometimes stays on the workspace-trust dialog with the cursor on No, exit and the auto-accept never tries. On Claude Code 2.1.294 this was 2 of 12 fresh launches. The journal shows no Auto-accepting workspace trust dialog line at all for those sessions. The pane sat there for over 20 s until I deleted the session, and it would have sat until the 90 s window closed. A second, smaller issue showed up in the same logs. Once such a session is deleted, the kill's own output starts the scan again: it logs all six attempts against a dead pane over about 8 s, then did not clear after retries.

Cause

_maybeAcceptTrustDialog() runs from onData, plus a one-shot timer after each keystroke it sends. Its throttle (TRUST_DIALOG_RETRY_MS) simply returned. With temporary debug logging around each return, a stuck launch looked like this:

+156ms   read: pane captured, blank (Claude has not drawn yet)
         dialog paints within the next 1.5 s -> the scan is throttled, call dropped
         the dialog is static -> no more output -> no more scans
+20.7s   session deleted -> kill output -> scan runs, buffer tail still shows the dialog

No keystroke had been sent, so no timer was pending, and nothing else calls the scan. CLAUDE.md already states the rule this breaks: "The scan must schedule its own next read (_trustDialogTimer, cleared in _clearAllTimers()), not rely on PTY output." The keystroke path honours it; the throttled path did not. The post-delete attempts come from the same path: stop() clears _trustDialogTimer, but the scan does not check _isStopped. So the next output chunk re-arms the timer, and the scan keeps reading the buffer tail of a dead pane.

Fix

  • A call refused by the throttle now schedules one deferred read on the existing _trustDialogTimer, for just past the end of the throttle. If a read is already scheduled (the post-keystroke timer, or an earlier deferral), that read covers it. The scan rate does not change, at most one read per throttle period while output flows, plus one trailing read after the output stops.
  • The scan returns at once on a stopped session, so it can no longer re-arm the timer after stop(). DELETE /api/sessions/:id reaches the session through stop(), which sets _isStopped first and then runs _clearAllTimers(), so a read deferred just before the delete is cancelled and any output after it is ignored.
  • The timer scheduling is shared by the keystroke path and the new deferral (_rescanTrustDialogIn()).

All three guards stay as they were: the 90 s startup window, the two-marker isTrustDialogScreen() match, and the 6-keystroke cap. So does the one-key-per-re-read rule, since a deferred read goes through the same trustDialogNextKey() path.

The six-attempt pattern from 2026-10-06 is exactly what a deleted stuck session logs (above), so I suspect that is what I saw then, but I cannot prove it now.

Tests

test/session-trust-dialog.test.ts:

  • "reads the dialog that painted inside the throttle of the read before it": a blank screen is read, the dialog paints 500 ms later inside the throttle, and no further output arrives. The first keystroke must still go out (an arrow, since the cursor is on No, exit). Red without the deferral.
  • "cancels a deferred read when the session is stopped": a throttled call arms one deferred read, and stop() leaves no timer pending. Red without the _trustDialogTimer clear in _clearAllTimers().
  • "neither scans nor arms a timer once the session is stopped": after stop(), two scans leave no pending timer and log nothing. Red without the _isStopped check. (My first version asserted on writes and passed without the guard, because writeViaMux already does nothing after stop, so it now asserts on the timer and the log, which is what the guard prevents.)

The existing auto-accept tests (attempt cap, never Enter on No, exit, stops once answered, window bound) pass unchanged. Full gate: 567 files passed, 11261 tests passed, 12 skipped.

Verified

Claude Code 2.1.294, a throwaway Codeman instance (CODEMAN_INSTANCE=w8trust, port 5187), one fresh mktemp -d /tmp/... directory per launch, pane sampled once a second, journal read per session.

Before (upstream master), observed: 12 launches. 10 cleared in two keystrokes, with the first arrow landing every time (so 0 of 10 showed the swallowed-arrow failure). 2 never got a keystroke. One of those was with debug logging on, which showed the single blank-screen read at +156 ms and nothing after it. Both then logged all six attempts after the session was deleted.

After (this branch, same temporary debug logging, removed before commit), observed: 12 launches, 12 cleared in exactly two keystrokes. In all 12 the first keystroke came from a deferred read about 1.75 s after start, i.e. the read that used to be dropped. There were no trust-dialog lines after any deletion.

Inferred, not measured: on a directory that is already trusted, the change costs at most one extra capture-pane per burst of output in the first 90 s, the trailing read after the burst ends.

Docs

The trust paragraph in CLAUDE.md and docs/architecture-invariants.md#workspace-trust-dialog-auto-accept now say that a throttled read is deferred, never dropped, and that a stopped session never scans.

Invariant touched: workspace-trust-dialog-auto-accept (restores "the scan must schedule its own next read" on the throttled path; the three guards are untouched).

Note

The companion PR #634 (holding idle while the dialog is on screen) touches neighbouring lines in src/session.ts, the same test file and CLAUDE.md. A trial merge of the two branches is clean and the merged test file passes. The two fixes are independent. This one makes the dialog get answered. That one stops a waiting agent from prompting into the dialog before it is answered.

…ropping it

The scan must schedule its own next read rather than wait for PTY output, but a call inside the throttle was simply dropped. The dialog's own paint is often the last output a launching pane produces and lands inside that throttle, so the session sat on the dialog with no keystroke ever sent. A stopped session no longer scans, since the kill's own output kept it answering a dialog in a dead pane.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant