[#1010] Bound Claude deferred-wake delivery under continuous TUI repaint#1020
Conversation
The Claude Code TUI repaints its status line ~5x/sec while idle, so the deferred-wake drain's idle timer — reset on every onData — never saw the 5s gap and a queued wake was starved indefinitely. Claude agents in file-chat mode were therefore never woken, while codex agents (whose TUI goes quiet between turns) worked normally. Adds a bounded deadline that output cannot postpone, gated to Claude via explicit session backend identity so codex/gemini keep their current genuine-quiescence-only behavior — a wall-clock deadline would otherwise barge into their legitimately long streaming turns. Safety contract: - The cap never preempts the #736 active-send suppression window: armed from the recentlySent path it re-arms once for the remainder. - Idle-gap delivery remains the preferred path; the cap is only the starvation escape hatch. - The onData listener and both timers are one per-key lifecycle, disposed on idle drain, cap drain, cleanup and session exit; a timer that outlives its cycle cannot consume or dispose a later one. - Delivery re-resolves the session by key and requires a running session with a live term, re-checking backend eligibility against the live session in case of a respawn onto another backend. - A per-key cooldown holds (never drops) further cap deliveries, so repeated mentions during one long turn cannot become a wake storm — which would trip the 30-hop loop guard and stop wake dispatch entirely. - The delayed submit ("\r") timer is now owned per key, so stopping a session inside its delay cannot write to a dead term. Tests drive the captured onData callbacks — the pre-existing stub never did, which is why repaint starvation shipped green. Cap timings are overridable so the regressions run at millisecond scale. Verified the core case fails with the cap disabled (11 assertions fail). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Epic Alignment: PASS
Standalone #1010 now advances the amended contract: Claude repaint starvation is bounded while Codex/Gemini retain genuine-quiescence-only delivery.
Checked (evidence)
- Structural gate: live PR body has filled
## EPIC Alignmentand## Self-Verification; live #1010 supplies the standalone EPIC Context and all eight scope/test requirements. - Backend safety:
server/index.js:648-663stamps backend identity;server/pty-dispatcher.js:121-130,206-211,250-253makes the cap Claude-only both at arm and fire time. - Lifecycle and liveness: per-cycle identity/live-session checks at
pty-dispatcher.js:151-188, full listener/timer cleanup at:262-275, and submit ownership/cleanup at:322-357prevent stale-cycle or dead-term writes. - Riskiest part: cap/suppression/cooldown interaction is explicit at
pty-dispatcher.js:202-239; deterministic repaint, non-Claude, suppression, cooldown, lifecycle, and liveness tests cover it atserver/pty-dispatcher.test.js:281-467. - Kill-list: scanned all items — clean; scope is limited to dispatcher/session identity/tests, with no turn-state detector or direct-wake change.
- CI:
gh pr checks 1020→test pass 1m8s(live).
Findings
- None.
Decision
The cap is fail-safe for unknown/non-Claude sessions, preserves the active-send window by re-arming, and retains deferred wakes without a cap-driven storm. Approval applies to a9dadab88e688222ee2d903a4dab02a4afd0892e.
|
@re2 REQUEST CHANGES at 3 files, +441/−38, base main, MERGEABLE, The one change requestThe ticket's test contract requires: "a stale callback cannot affect a subsequent cycle." Test 18b claims to cover it — The reason is that 18b never produces a stale timer: the cap drain calls This matters because the cross-cycle hazard was a blocker in review — a timer outliving its cycle consumes a later cycle's pending wake and disposes its listener. // arm a Claude cycle under repaint, then SUPERSEDE it before its cap fires
dispatchToAgentPTY(...); // cycle 1 armed
const sentinel = { disposable: null, idleHandle: null, capHandle: null, capRearmed: false };
_drainListeners.set(KEY, sentinel); // a later cycle now owns the key
await sleep(pastTheCap);
// guard present → 0 injections, sentinel still registered, pending wake preserved
// guard removed → 1 injection, sentinel DISPOSED, pending wake CONSUMEDMeasured at this SHA — with the guard: No production change needed — only a test that fails when the guard is removed. If you'd rather not add the case, then 18b's assertion should be renamed so it stops claiming coverage it doesn't provide, but I'd prefer the real test given this was a blocker. Answers to the three things you asked to have challengedRe-arm-once (scope 2) — I accept your choice, with one edge worth a comment. Accepted latency regression — confirmed, I accept it. The test seam — it strengthens rather than weakens the pre-existing cases. The old stub's Checked (evidence)Pre-fix failure reproduced independently, as you asked. With Mutation-tested each blocker's guard individually — three of four are genuinely pinned:
Backend identity wiring (scope 1). Lifecycle (scope 4, 7). One Suite at this SHA in a clean worktree: One observation, not a request. |
re2 on PR #1020: test 18b asserted the "a stale callback cannot affect a subsequent cycle" contract but could not discriminate it — by the time the cap drain returns, cleanupDrainListener has already cleared both handles, so no stale timer ever exists. Replacing the guard with `() => true` left the suite fully green. Adds a case that constructs the stale timer explicitly: arm a Claude cycle under repaint, supersede it in _drainListeners while its cap is still pending, then let that cap fire. Asserts it performs no injection, does not dispose the superseding cycle's listener, and does not consume its pending wake. Verified discriminating — with the guard replaced by `() => true` all three assertions fail (injection lands, sentinel disposed, pending wake consumed). Also mutation-tested the two guards not covered in re2's sweep: dropping the fire-time running/live-term check and dropping the submit-timer cancellation both fail their cases. No production behavior change. The one source edit documents the `capRearmed` bound re2 flagged: the MAX_DEFER_MS + ACTIVE_SUPPRESSION_MS worst case holds for a single post, and re-arming per post is deliberately not done since a chatty agent could then extend deferral indefinitely — recreating the starvation this cap bounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Epic Alignment: PASS
The delta strengthens the amended #1010 lifecycle contract without changing its Claude-only delivery design.
Checked (evidence)
- Re-review range:
a9dadab88e688222ee2d903a4dab02a4afd0892e..01c2c12a1f5367cce4c957c376067052322b7e97(two changed files, +37 test lines and an explanatory comment). - Previous finding resolution:
server/pty-dispatcher.test.js:464-499constructs a superseded cycle with its cap still pending and verifies the stale callback neither injects, disposes the replacement, nor consumes its pending wake. - Riskiest part: the test specifically exercises the per-cycle identity guard at
server/pty-dispatcher.js:153-157; it would fail if that guard were removed. - Kill-list: scanned new ranges — clean; no production logic change or scope creep.
- CI:
gh pr checks 1020→test pass 57s(live).
Findings
- None.
Decision
The re-review delta closes the only identified evidence gap and preserves the approved implementation. Approval applies to 01c2c12a1f5367cce4c957c376067052322b7e97.
|
@re2 APPROVE at Re-reviewed the delta only ( The gap is closed, and I confirmed it the same way I found itTest 20 constructs the stale timer explicitly instead of relying on one existing. Mutation-tested at this SHA — replacing the guard with (at All four blocker-derived guards are now genuinely covered. Answering the margin disclosure (msg 1775 — I hadn't seen it when I posted at
|
Closes #1010.
Claude agents in file-chat mode were never woken: the deferred-wake drain's idle timer is reset on every
onData, and the Claude Code TUI repaints its status line ~5x/sec while idle, so the required quiet gap never arrived. This adds a bounded deadline that output cannot postpone — Claude only, under the full safety contract from the amended ticket.EPIC Alignment
Implementation, against the 8 scope items
spawnAgentPtynow stampsbackend: cliBaseFromCommand(command)on the session (server/index.js:657-663), reusing the helper the spawn/arg paths already use soclaude,/usr/bin/claudeandclaude --fooall resolve toclaude. The dispatcher gates on it viacapEligible(); a session without the field is treated as ineligible, so the fail-safe direction is pre-file-chat: Claude agents never woken — PTY idle heuristic starved by continuous TUI repaint #1010 behavior rather than a barge-in. Butler needs no change — it lives outsideagentSessionsand is never dispatched to.ACTIVE_SUPPRESSION_MSwhen it fires inside that window, rather than raising the constant. This keeps the common-case latency at the cap while never injecting inside the [#713] File-chat: agents loop responding to each other's pings #736 window. Bounded worst case:MAX_DEFER_MS + ACTIVE_SUPPRESSION_MS.onDatadisposable and both timer handles live on a single per-cyclestateobject, cleared on idle drain, cap drain,cleanupDrainListenerandcleanupSession. Cross-cycle staleness is closed structurally: a drain only proceeds if_drainListeners.get(key) === state, so a timer that outlived its own cycle cannot consume or dispose a later one.state === "running"plus a liveterm. The cap additionally re-checkscapEligible()against the live session, so an agent respawned onto codex/gemini after arming cannot be cap-injected (the wake stays deferred for the idle path).CAP_COOLDOWN_MS(=ACTIVE_SUPPRESSION_MS): a cap injection has no evidence the turn ended, so the agent is presumed active for the same window as its own chat post. Held wakes are deferred, never dropped. This is the loop-guard protection — a wake storm would trip the 30-hop guard, anddispatchToAgentPTYreturns early on a paused guard before any wake logic, which would stop delivery entirely."\r"timer is tracked per key and cancelled bycleanupSession, so stopping a session inside the ~300 ms submit delay can no longer write to a dead term.queuePendingWake()doc no longer describes an unimplemented since_id high-water mark.Self-Verification
Deterministic test contract. Timings are read through an indirection with a
_setTimingsForTestseam, so the regressions run at millisecond scale instead of the shipped 10 s cap / 30 s cooldown. The PTY stub'sdispose()now actually detaches the callback (modelling node-pty), which makes listener disposal directly assertable.All six required cases, plus two extras:
cleanupSession: no pending wake, listener, timer or submit handle; no stale cross-cycle effectif (false && capEligible(session))) the suite reports 55 passed, 11 failed, the core case among them (#1010 REGRESSION: a continuously repainting Claude TUI receives its wake at the cap). Restored before committing.node server/pty-dispatcher.test.js→ 69 passed, 0 failed (was 41 assertions pre-change; all pre-existing ones still pass unmodified on the shipped timings).npm test→ 64 passed, 0 failed, 2 skipped (the two skips are the pre-existing Jest-style files, out of Unref routes.js module-load pollers + add cross-platform 'npm test' (fix full-suite hang) #836 scope).npx tsc --noEmit→ clean (exit 0).npm run build→ succeeds, 9/9 static pages.main@7c4830e.Review notes
Two judgment calls worth a second opinion:
isAgentBusy()is still raw-output based, a repainting Claude agent takes the defer path for every mention, so its mention→wake latency is now the cap (~10 s) rather than the ~1 s coalesce path. That is the ticket's bounded-latency trade; the genuine turn-state detector that would restore fast delivery is explicitly out of scope here.🤖 Generated with Claude Code