Repository navigation
C11-401: a slow inbox scan no longer costs a Claude seat its idle edge - #659
Conversation
`c11 claude-hook` arms one 250 ms socket window before it connects. The Stop case then runs the C11-257 mailbox drain, whose inbox scan stats every directory under the mailbox workspaces root. It runs on a panel's first Stop and whenever the 5-minute scan cache is stale. On a machine with ~12.7k workspace directories that scan alone took 0.2 s warm and over 1 s cold, so the window was spent before the lifecycle report began. The Stop draft was spooled (replayed only at the next launch, as history), the hook exited 0 with no output, and the panel read as working forever with its stdin mail buffered. launch-agent seats hit it every time: each is a new panel whose first turn ends on an uncached scan, and then they wait for mail. After a drain that claims nothing, the lifecycle report now gets a fresh window, still capped by the claim cutoff. A drain that claims mail still blocks the Stop with no lifecycle report. tests/test_mailbox_hook_drain_cli.py case 10 grows the workspaces root until the scan costs twice the window, then asserts that the Stop reaches c11 live, nothing is spooled, the hook prints OK, it still exits promptly against a c11 that never answers, and a Stop that delivers mail sends no report. Against the unfixed CLI the first three fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A tagged build launched from inside an agent seat inherited that seat's C11_CLAUDE_WRAPPER_REENTRY, agent identity and Claude Code session variables. Its terminals passed them on, so Resources/bin/claude took the reentry path and skipped its hooks: a Claude seat in the tagged build reported no lifecycle at all, and with CLAUDE_CODE_CHILD_SESSION set it saved no transcript. Validation runs of seat lifecycle on such a build measured a hookless seat. The script now unsets those markers; account and config variables stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deep review — PR #659: C11-401 slow inbox scan no longer costs a Claude seat its idle edgeThank you for this one — the writeup is excellent. The cause narrative (single 250 ms window armed before connect, the C11-257 drain's SummaryThe Stop path re-arms a second, fresh socket window after its filesystem-only mailbox drain, so the lifecycle report ( AlignmentIn scope. This is a socket/CLI correctness bug fix — squarely in the "clearly wanted" column of the policy, and it strengthens the agent-facing lifecycle primitive rather than adding surface. It correctly routed to deep review (not the autonomous lane) because the second commit touches Correctness walkthrough (what I verified)
Findings
Neither of these touches the shipped fix. Out-of-scope items you flagged (for the maintainer, not this PR)Your "found along the way" notes are good signal and I'd encourage filing them: the RecommendationMerge after fixes — and the only "fix" is finding #1, which is about the test's portability, not the change itself. The CLI and launch-script changes are correct as written; if you'd rather land the fix now and harden the test's scaling in a follow-up, that's a reasonable call for the maintainer to make. Nice work. 🌉 Drawbridge deep review (lane 3) — an automated pass; a maintainer has been notified and makes the final call. |
Cause
Claude Code seats started with
c11 launch-agentreported one lifecycle edge at launch (unknown → working) and then nothing more. c11 kept them "working", so stdin mail pushed to them was buffered and never flushed.The Stop hook does run. Its idle edge was dropped inside the hook process:
c11 claude-hookarms a single 250 ms socket window before it connects.MailboxDrain.panelInboxURLs) stats every directory under the mailbox workspaces root. It runs on a panel's first Stop, and again whenever the 5-minute scan cache is stale.agent.event.appendthen fails at once and the Stop draft is spooled. The spool replays only at the next launch, as history, and history cannot displace live state. The hook exits 0 with no output.launch-agent seats hit this every time. Each one is a new panel, so its first turn ends on an uncached scan, and these seats usually wait for mail after that first turn. Interactive panels lose Stops the same way, but less visibly: a two-day production sample showed nine lost Stops on one interactive seat, each lining up with a stale scan cache. The operator's next prompt and a later fast Stop hid them.
Fix
After a Stop drain that claims nothing, the lifecycle report gets a fresh socket window, still capped by the C11-257 claim cutoff. Socket waiting stays bounded at two windows. The mail-first order is unchanged: a drain that claims mail still blocks the Stop, and no lifecycle report is sent.
A second commit fixes
scripts/launch-tagged-automation.sh. A tagged build launched from inside an agent seat inherited that seat'sC11_CLAUDE_WRAPPER_REENTRY, agent identity and Claude Code session variables. Every Claude seat in that build then skipped its hooks and saved no transcript, so any lifecycle validation run on it measured a seat with no hooks. The script now unsets those variables; account and config variables are kept.Tests
tests/test_mailbox_hook_drain_cli.pygains case 10. It grows a fixture workspaces root until the inbox scan costs twice the window on the machine running it (80k directories here). Then it checks, against a fake c11 socket:agent.event.appendreaches c11 live;OK;Red with the cause restored: against the unfixed CLI, the first three checks fail with 0 requests, 2 spooled drafts, and empty stdout. That matches the production
hasOutput:false. The full file passes against the fixed CLI.Also run against the fixed CLI:
test_journal_claude_resolution_cli.pyandtest_claude_hook_permission_cli.pypass.test_claude_hook_session_mapping.py,test_claude_hook_missing_socket_error.pyandtest_claude_wrapper_hooks.pyfail identically on unfixedmain; these failures predate this change.Live proof (tagged Debug build
idle-401, built on Atlas, run against the real mailbox root)launch-agent --type claude-code --model sonnetstarted a seat with the wrapper's--settingshooks.unknown → working → idle. The Stop journal row isapplied / state_transition / idle. Its Stop hook ran 408 ms, over the 250 ms window, on an uncached scan (the scan cache was written in the same second), and still printedOK.c11 mailbox send --to proof-seat …, with nobody typing: dispatch showshandler stdin outcome ok(notbuffered) andmailbox.delivered via: push. The seat took the message as a new turn and repliedPONG-401.Found along the way (not in this PR)
system.capabilities. The app answers it on the main actor;agent.event.appenditself runs on a socket worker. A main-thread stall longer than the window during a hook spools its edge. The live run hit this once: a 3 s Debug-build stall, right after the first mail resized the panel bar, cost the mail turn's UserPromptSubmit and Stop edges. Production logged 4 such hang clusters in two days. Candidate fixes: answersystem.capabilitieson the socket worker, or send the journal append without the vocabulary probe. Either needs its own review.🤖 Generated with Claude Code