fix(herdr): share pane-independent reports across the badge fan-out; E3 isolation audit (#543) - #557
Conversation
…illed children as timed out (#543) refresh-all-badges capped each child at 10s while every child re-ran statusReport (5s) + usageReport (12s) — pane-independent work that alone exceeded the cap, so a slow but healthy refresh was killed mid-write and the serial fan-out blocked startup for N x cap. The wall-clock budget belongs to the parent (it owns the serial fan-out); the children's per-call timeouts are defense against a hung CLI, not a budget. So instead of raising the cap or squeezing the children, the parent now runs status/usage once, shares them via a temp file (CCXRAY_BADGE_SHARED_REPORT, fail-open in the child), and passes each agent's session in the context so children skip their own `herdr agent list`. Worst-case child work drops to ~5s of sidebar writes. A child killed at the cap (CCXRAY_BADGE_CHILD_TIMEOUT_MS, default 10s) is now reported as "timed out" separately from "failed" — it never got to report the failure it knows how to report since #553. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…a memory (E3) #553 added a scan root outside CCXRAY_HOME (claudeProjectRoots reads $HOME/.claude*/projects when CCXRAY_IMPORT_HOMES is unset), and every pre-existing sessionSummaryDetails test silently statted the developer's real transcripts — green only because no real transcript is named s1.jsonl, the #407 shape. The call sites were fixed and verified 7 -> 0 with ad-hoc instrumentation; that was evidence, not enforcement. Two mechanisms now stop the next call site from reintroducing it: - An audit test (invariant-encapsulation style source scan): any sessionSummaryDetails call in this file whose opts set CCXRAY_HOME without CCXRAY_IMPORT_HOMES fails the suite. Verified by mutation: removing one pin turns it red naming the offending call. - pluginEnv() defaults CCXRAY_IMPORT_HOMES to the empty NO_TRANSCRIPTS root for every spawned script (overridable per test), closing the same leak one layer down where the audit cannot see the child's env. Refs ADR 0015 R4; docs/testing.md updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-less panes (grok review)
Review findings from the grok merge-gate pass (codex stand-in):
1. Failure poisoning (high): a transient parent status/usage failure was
written to the shared file and consumed by every child, painting all
panes "no hub / not linked" where the old per-child retry confined the
blip to one pane. The parent now shares only reports with ok=true; a
child that finds its report missing recomputes its own — pre-share
behavior, confined to that child. Differential test: a usage report
that fails once must be recomputed by each child (3 usage runs), not
broadcast (1 run).
2. Incomplete agent-list dedupe (high): a pane without a native session
id still triggered the child's own `herdr agent list`. The parent now
marks the context agent_session_known, an explicit flag only the
fan-out sets (a bare null agent_session could come from any context
author); the child trusts the parent's "no session" answer. The
fan-out test gains a session-less third pane and still expects one
agent list call.
3. Minimal shared DTO (low): the file now carries {status:{ok,parsed},
usage:{ok,data}} instead of raw spawnSync results, so no future
consumer can depend on a JSON-flattened Error.
Also: temp dir creation moved inside try/finally; fallback test asserts
status re-runs too; README and docs/testing.md narrowed to what the
mechanisms actually enforce (the audit is a literal-span tripwire, the
pluginEnv default is the structural guard).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
grok review transcript (merge gate, codex stand-in)Raw output of
Before: each child independently called After: one parent failure is written once and reused by every child. All sidebars can render the failure state for a full startup fan-out. That is a real correctness regression under flaky hub/CLI conditions. Fail-open is only for malformed files, not for failed-but-well-formed reports. Fix direction: treat 2. Agent-list “once” optimization is incomplete — and the test overclaims itWhere:
if (!nativeSessionId && context.agent_session?.kind === 'id') {
nativeSessionId = context.agent_session.value;
}
if (!nativeSessionId && targetPaneId) {
const report = herdrAgentReport({ env }); // still runsIf parent saw The new test uses only: { agent_session: { kind: 'id', value: 'sess-1' } },
{ agent_session: { kind: 'id', value: 'sess-2' } },and asserts agent list count Fix direction: share a compact Medium3. Fail-open on bad shared file reintroduces the original 10s killWhere: If the file is missing, unreadable, non-JSON, or fails That is intentional for the event-driven path (no env var). For the fan-out path, parent always sets No test covers: parent wrote a file that fails validation → children time out / call counts explode. Fix direction: if env var is set and file is bad, fail closed with a clear error (or parent retries write), rather than silently falling back into the known-bad budget. 4. Isolation audit is weaker than the docs claimWhere: new The audit only flags call spans that literally contain the substring const env = { CCXRAY_HOME: makeHome() }; // HOME set outside the call
sessionSummaryDetails(data, { env }); // span has no CCXRAY_HOME → skippedsessionSummaryDetails(data, { env: pluginEnv({ CCXRAY_HOME: home }) });
// may or may not include both strings depending on inliningAlso: only scans
5. Residual wall-clock risk is only half-fixed (product / issue fidelity)Where: serial loop in After the fix, worst-case per child (from
Under 10s when shared reports work and sessions are present. Good. Still true:
Not a logic bug, but the issue’s “startup blocked for minutes” is only partially addressed. Low6. Shared payload is a full
|
| Standard | Assessment |
|---|---|
| Fail-on-old for #543 call-count fix | Good — old code yields status/usage/agent-list 2/2/3, new expects 1/1/1 |
| Fail-on-old for timeout labeling | Good — exact timed out (over 500ms) string would fail pre-change |
Never read real ~/.ccxray / ~/.claude |
Mostly good for spawns via pluginEnv + explicit NO_TRANSCRIPTS; audit is incomplete |
| Differential tests for failure-poisoning / null sessions | Missing |
| Isolation of new helpers | Counting bins use tmp + CCXRAY_BIN / HERDR_BIN_PATH — solid |
The timeout test’s herdr mock (sleepMs on every non-agent list command) is a reasonable stand-in for a stuck metadata write; 500ms vs 3000ms should be stable.
What looks solid
- Root cause match to refresh-all-badges 10s child cap vs ~27s internal budget in refresh-badges #543: pane-independent
status/usagemoved to parent; child budget can fit metadata writes. - Event path unchanged when
CCXRAY_BADGE_SHARED_REPORTunset — good. usageis already called without--cwdin children; sharing one report is contract-correct for that call shape.sessionSummaryDetailsstill reads live index/transcripts per pane; sharing does not freeze session matching to the parent snapshot (only usage meta/cache-style fields).- Timed-out vs failed exit/summary split is a real observability win vs folding into
failed. pluginEnv()defaultCCXRAY_IMPORT_HOMES=NO_TRANSCRIPTSis the right spawn isolation fix (ambientCCXRAY_*was already stripped, so unset meant real~/.claude*/projects).
Suggested merge bar
Request changes (or explicit follow-up commits) for:
- Do not share failed
status/usageresults (or retry); add a fail-on-old test that a parentusagefailure does not force all children to skip recompute. - Share full per-pane session resolution (including “no session”) so agent list is truly once; extend the count test with a null-session agent.
- Narrow docs/audit claims to what is actually enforced; consider fail-closed when
CCXRAY_BADGE_SHARED_REPORTis set but unreadable.
I would not block on the timeout string split, temp cleanup, or payload trimming alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
摘要
修 #543:
refresh-all-badges給每個子行程 10s cap,但每個子行程都各自重跑 pane 無關的statusReport(5s) +usageReport(12s),光這兩項就超過 cap——慢但健康的 refresh 被殺、串列 fan-out 在 startup 阻塞 N × cap。現在 parent 跑一次、只分享成功的報告(避免瞬時失敗污染全部 pane)、以 temp file 共享;被 cap 殺掉的子行程回報為timed out與failed區分。另外落地 handoff 的 E3 稽核測試:sessionSummaryDetails呼叫點必須 pinCCXRAY_IMPORT_HOMES,用機制而非記憶防止 #407 型洩漏回歸。Which layer owns the budget (the #543 design question)
The issue rejected both obvious fixes, and this PR agrees:
ccxray usagegenuinely needs its 12s worst case.The resolution: the parent owns the wall-clock budget because it owns the serial fan-out — N × cap is what the user experiences at startup. The children's per-call timeouts are defense against a hung CLI, not a budget contract. The mismatch is fixed not by aligning the two numbers but by moving the pane-independent work (
status,usage,agent list) up to the layer that owns the budget, run once, shared down viaCCXRAY_BADGE_SHARED_REPORT(a temp file carrying a minimal{status:{ok,parsed}, usage:{ok,data}}DTO). Worst-case child work drops from ~24s to ~5s of session matching, layout lookup, and sidebar writes — comfortably inside the unchanged 10s cap (CCXRAY_BADGE_CHILD_TIMEOUT_MSoverrides, mainly for tests).Sharing rules, each with a differential test:
ok: true). A child that finds its report missing recomputes its own — the pre-share behavior, confined to that child — so a transient parent failure cannot paint every pane "no hub / not linked".agent_session_known, an explicit flag only the fan-out sets, so session-less panes stop re-runningherdr agent listtoo.timed outseparately fromfailed: since Mark a Herdr badge whose numbers the transcript has outrun, and rescan to fix it #553 the child's exit code is honest, but a killed child never gets to use it.E3 audit test (second commit)
#553 added a scan root outside
CCXRAY_HOME(claudeProjectRootsreads$HOME/.claude*/projectswhenCCXRAY_IMPORT_HOMESis unset). The prior session fixed the test call sites and measured 7 → 0 real-path accesses — evidence, not enforcement. This PR adds the mechanism, two layers:pluginEnv()default (structural): every spawned script getsCCXRAY_IMPORT_HOMES=NO_TRANSCRIPTS(overridable), so a child process can never fall through to the developer's real transcripts.sessionSummaryDetailscall span intest/herdr-plugin.test.jsthat literally containsCCXRAY_HOMEwithoutCCXRAY_IMPORT_HOMESfails the suite. Scope stated honestly indocs/testing.md: it scans literal call spans; an env object assembled outside the call escapes it.Refs ADR 0015 R4.
Verification (docs/verification-principles.md)
All differentials taken in throwaway worktrees, never over a dirty tree.
Against origin/main (99ac693) + new test file:
runs status, usage, and agent list once for the whole fan-out— FAILS on old withexpected one shared usage run, saw: status | usage --json --last 24h | status | usage --json --last 24h(each child re-ran both); PASSES on new.reports a child killed at the cap as timed out, not refreshed— FAILS on old (no timed-out bucket); PASSES on new.CCXRAY_IMPORT_HOMESpin from a call site turns the audit red naming the offending call; green with all pins present.Against the pre-review-follow-up commit (2798611) + final test file:
does not poison the fan-out when the parent usage report fails once— FAILS on the share-everything version (saw: status | usage --json --last 24h, one poisoned run broadcast to all panes; the captured herdr log shows every pane writtensummary=ccxray: not linked); PASSES on final (3 usage runs: 1 failed parent + 2 child recomputes, badges render real data).agent_session_known(secondagent listcall from the p3 child visible in the log); PASSES on final.Suite:
CCXRAY_HOME=$(mktemp -d) npm test→ 2190/2190 pass, exit 0 on the final tree. (One earlier run had a single unrelated flake,hub-client-signal.e2etiming out under full-suite load on a machine carrying known leaked processes; it passes in isolation and in the other full runs, and shares no files with this diff.)No-regression control for the unshared path: the pre-existing exact-token assertions on the event-driven single-pane path (
refresh-badges computes tokens…,targets the pane carried by a Herdr event hook) pass unchanged.Not run:
verify-render.sh(interactive, needs a live Herdr pane + human eyes). This PR does not touch token computation or the stale-marker path — only where the fan-out children get their status/usage data.Review gate (grok, codex stand-in)
codex is out of quota until 2026-08-20; the owner accepted grok as the stand-in for #553. grok's verdict on the initial diff: core fix right, fail-on-old tests real; three change requests. Disposition:
ok:true; differential test added)agent_session_knownflag; test gains a session-less pane)🤖 Generated with Claude Code