Skip to content

C11-401: a slow inbox scan no longer costs a Claude seat its idle edge - #659

Merged
BenevolentFutures merged 2 commits into
mainfrom
fix/C11-401-launch-agent-idle
Oct 12, 2026
Merged

BenevolentFutures merged 2 commits into
mainfrom
fix/C11-401-launch-agent-idle

Conversation

@BenevolentFutures

Copy link
Copy Markdown
Contributor

Cause

Claude Code seats started with c11 launch-agent reported 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-hook arms a single 250 ms socket window before it connects.
  • The Stop case then runs the C11-257 mailbox drain. The drain is filesystem-only, but its inbox scan (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.
  • On a long-lived install with about 12.7k workspace directories, that scan alone takes about 0.2 s warm and over 1 s cold. The window is gone before the lifecycle report starts.
  • agent.event.append then 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's C11_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.py gains 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:

  • the Stop's agent.event.append reaches c11 live;
  • nothing is left in the journal spool;
  • the hook exits 0 and prints OK;
  • the hook still exits promptly when c11 never answers (scan + 263 ms here);
  • a Stop that delivers mail sends no lifecycle report.

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.py and test_claude_hook_permission_cli.py pass. test_claude_hook_session_mapping.py, test_claude_hook_missing_socket_error.py and test_claude_wrapper_hooks.py fail identically on unfixed main; these failures predate this change.

Live proof (tagged Debug build idle-401, built on Atlas, run against the real mailbox root)

  1. launch-agent --type claude-code --model sonnet started a seat with the wrapper's --settings hooks.
  2. First turn: lifecycle unknown → working → idle. The Stop journal row is applied / 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 printed OK.
  3. c11 mailbox send --to proof-seat …, with nobody typing: dispatch shows handler stdin outcome ok (not buffered) and mailbox.delivered via: push. The seat took the message as a new turn and replied PONG-401.
  4. A window-only screenshot of the tagged app is kept with the ticket, not attached here.

Found along the way (not in this PR)

  • A main-thread stall drops hook edges too. Every Claude hook's first socket request is the CLI's vocabulary probe, system.capabilities. The app answers it on the main actor; agent.event.append itself 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: answer system.capabilities on the socket worker, or send the journal append without the vocabulary probe. Either needs its own review.
  • The mailbox workspaces root is never pruned (about 12.7k directories on one install), and the inbox scan grows with it.
  • C11-398's direction (an unsupported "working" decays) would bound any lost Stop edge, whatever the cause.

🤖 Generated with Claude Code

Atin and others added 2 commits October 11, 2026 23:38
`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>
@BenevolentFutures
BenevolentFutures marked this pull request as ready for review October 12, 2026 05:49
@github-actions github-actions Bot added the drawbridge:review Drawbridge: routed to maintainer review label Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Deep review — PR #659: C11-401 slow inbox scan no longer costs a Claude seat its idle edge

Thank you for this one — the writeup is excellent. The cause narrative (single 250 ms window armed before connect, the C11-257 drain's panelInboxURLs scan statting ~12.7k workspace dirs eating the whole budget, agent.event.append then failing and spooling as history that can't displace live state) is specific, matches the code, and is backed by a live idle-401 run plus a red/green test. That's the standard a deep-review PR wants to meet.

Summary

The Stop path re-arms a second, fresh socket window after its filesystem-only mailbox drain, so the lifecycle report (appendJournal → agent.event.append, then the fallback report_agent_activity) no longer competes with the scan that just ran. The second commit stops launch-tagged-automation.sh from leaking the launching seat's wrapper/identity/session env into a tagged build's terminals. Both changes are minimal and correct; I found no blockers.

Alignment

In 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 scripts/**, a deny-listed path; that escalation is working as intended, not a problem with the PR. No tenant-config writes, no skill-contract surface change (no new command/flag/metadata), no user-facing strings, no typing-latency hot path. The diff and (empty) discussion contain no prompt-injection attempts.

Correctness walkthrough (what I verified)

  • The ceiling is real. socketCeiling is priorProcessDeadline, captured at CLI/c11.swift:2542 after boundSocketToClaimCutoff() (:2536) set processDeadline to the absolute 6 s-process-age claim cutoff (MailboxDrain.claimDeadlineSeconds = 6). So the re-armed window min(socketCeiling ?? .distantFuture, now + 0.250) is genuinely capped by the C11-257 cutoff, exactly as the comment claims — a scan that somehow ran past 6 s process age would correctly get a past/empty window and fail fast rather than extend toward the harness's 10 s kill.
  • Placement is right. The reset sits after the deliverMailboxHookDrain(...) { return } early-exit (:19290), so a Stop that claims mail still returns first and sends no lifecycle report — mail-first ordering preserved. It precedes the reportAgentActivity/appendJournal calls at :19311, which are the socket calls that were starving. The write-failed fall-through (deliverMailboxHookDrain returns false) and the no-mail case both reach the reset, which is what you want.
  • Shared-constant swap is consistent. Both the initial arm (:2549) and the new reset use Self.claudeHookSocketWindowSeconds, so the two windows can't drift.
  • idle subcommand. The reset also runs for case "stop", "idle" when subcommand == "idle" (no scan happened, no claim cutoff was set, so socketCeiling is nil). Re-arming a fresh 250 ms there is harmless — not a bug, just worth being explicit that it's intentional/benign.
  • launch script. OPEN_ENV starts with env, so the added -u VAR entries are valid unset flags; unsetting C11_CLAUDE_WRAPPER_REENTRY, the agent-identity vars (both C11_* and CMUX_*), and the CLAUDE_CODE_* session markers is the correct scope, and account/config vars are deliberately kept.

Findings

  1. minor — the new test (tests/test_mailbox_hook_drain_cli.py, case 10 / grow_workspaces) can false-fail on fast hardware. It grows the workspaces root until the scan costs 2 × 250 ms, capped at limit=320_000 dirs. On a fast NVMe with a warm dentry cache, statting even 320k directories may stay under 500 ms, at which point check(cost >= 2 * SOCKET_WINDOW_MS, ...) fails even though the CLI is correct — the assertion is about the fixture, not the code under test. Suggest either scaling limit until the target is actually reached (loop/raise rather than a hard cap) or skipping case 10 with a clear message when the target can't be met on this machine, so a correct binary never reports red. (CI only syntax-checks this file per the mailbox-parity policy, so this won't break the pipeline — it's a local-run footgun for the next person who runs it to verify a fix.)

  2. nit — test runtime/footprint. Creating up to 320k directories and re-scanning them three times per timed() measurement (with the scan cache removed each iteration) can take a while and churn a lot of inodes. It's correctly cleaned up (root resolves under fx.home, so fx.cleanup()'s rmtree(self.home) removes the grown tree — I checked), so no leak; just flagging that an interrupted run leaves a large temp tree behind until the OS reaps /tmp.

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 system.capabilities main-thread-stall path dropping edges independent of the scan, the unbounded growth of the workspaces root, and C11-398's "unsupported working decays" direction that would bound any lost Stop whatever the cause. The current PR deliberately doesn't try to solve those, which is the right call — keep it focused.

Recommendation

Merge 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.

@BenevolentFutures
BenevolentFutures merged commit 1f5d046 into main Oct 12, 2026
17 checks passed
@BenevolentFutures BenevolentFutures mentioned this pull request Oct 12, 2026
1 of 4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

drawbridge:review Drawbridge: routed to maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant