Skip to content

fix(herdr): share pane-independent reports across the badge fan-out; E3 isolation audit (#543) - #557

Merged
lis186 merged 4 commits into
mainfrom
fix/543-badge-refresh-timeout
Aug 17, 2026
Merged

fix(herdr): share pane-independent reports across the badge fan-out; E3 isolation audit (#543)#557
lis186 merged 4 commits into
mainfrom
fix/543-badge-refresh-timeout

Conversation

@lis186

@lis186 lis186 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

摘要

#543refresh-all-badges 給每個子行程 10s cap,但每個子行程都各自重跑 pane 無關的 statusReport(5s) + usageReport(12s),光這兩項就超過 cap——慢但健康的 refresh 被殺、串列 fan-out 在 startup 阻塞 N × cap。現在 parent 跑一次、只分享成功的報告(避免瞬時失敗污染全部 pane)、以 temp file 共享;被 cap 殺掉的子行程回報為 timed outfailed 區分。另外落地 handoff 的 E3 稽核測試:sessionSummaryDetails 呼叫點必須 pin CCXRAY_IMPORT_HOMES,用機制而非記憶防止 #407 型洩漏回歸。

Which layer owns the budget (the #543 design question)

The issue rejected both obvious fixes, and this PR agrees:

  • Raising the parent cap makes startup blocking worse (serial fan-out × longer cap).
  • Lowering the child's internal budgets changes nothing — ccxray usage genuinely 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 via CCXRAY_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_MS overrides, mainly for tests).

Sharing rules, each with a differential test:

  • Only usable reports are shared (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".
  • The parent's agent-list resolution is trusted, including "no session id": the fan-out context carries agent_session_known, an explicit flag only the fan-out sets, so session-less panes stop re-running herdr agent list too.
  • A child killed at the cap is reported as timed out separately from failed: 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 (claudeProjectRoots reads $HOME/.claude*/projects when CCXRAY_IMPORT_HOMES is 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 gets CCXRAY_IMPORT_HOMES=NO_TRANSCRIPTS (overridable), so a child process can never fall through to the developer's real transcripts.
  • Audit test (lint-class tripwire): any sessionSummaryDetails call span in test/herdr-plugin.test.js that literally contains CCXRAY_HOME without CCXRAY_IMPORT_HOMES fails the suite. Scope stated honestly in docs/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 with expected 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.
  • E3 audit by mutation: removing one CCXRAY_IMPORT_HOMES pin 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 written summary=ccxray: not linked); PASSES on final (3 usage runs: 1 failed parent + 2 child recomputes, badges render real data).
  • Session-less third pane — FAILS on the version without agent_session_known (second agent list call 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.e2e timing 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:

grok finding severity disposition
1. Failed shared reports poison every pane High Fixed (share only ok:true; differential test added)
2. Agent-list dedupe incomplete for session-less panes; test overclaimed High Fixed (agent_session_known flag; test gains a session-less pane)
3. Bad shared file when env var set → fail closed? Medium Declined: single writer, synchronous write before any spawn — the corruption path is theoretical; fail-open degrades to exactly the pre-share behavior, while fail-closed would blank every badge on a glitch
4. Audit weaker than docs claimed Medium Fixed (docs narrowed: pluginEnv default is the structural guard, audit is a literal-span tripwire)
5. Parent pays up to ~17s up front; fan-out still serial Medium Accepted, documented: the up-front cost buys a badge that actually renders (old: killed child, nothing renders); parallelizing the fan-out is out of scope — #543's stated fix direction is the sharing
6. Share minimal DTO, not raw spawnSync blobs Low Fixed
7. Temp dir lifecycle edges Low Fixed (mkdtemp inside try/finally)
8. Timeout classification thin on tests (mixed buckets, clamp) Low Declined — string concatenation and a numeric clamp; the load-bearing classification is tested
9. Fallback test under-asserted Low Fixed (asserts status re-runs too)
10. Test helper tmp dirs never removed Low Declined — matches the file's existing helper pattern

🤖 Generated with Claude Code

Justin Lee and others added 3 commits August 17, 2026 18:27
…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>
@lis186

lis186 commented Aug 17, 2026

Copy link
Copy Markdown
Owner Author

grok review transcript (merge gate, codex stand-in)

Raw output of grok -p over the initial diff (before the follow-up commit 63a0b83 that addressed findings 1/2/4/6/7/9):

I'll review this PR adversarially against the real code and tests—shared-report contract, timeout accounting, isolation, and fail-on-old coverage.Found the repo. Reading the shared-report path, child contracts, and tests next.Checking one more contract detail around shared-report validation and agent-session short-circuit behavior:## PR review: shared status/usage for `refresh-all-badges` (#543)

**Verdict:** The core fix is right and the fail-on-old tests for the main regression are real. I would not block solely on the timeout bucket. I would block or require follow-up on **failure poisoning**, **incomplete agent-list dedupe**, and **audit false confidence**.

Reviewed against `main`’s `refresh-badges.js` / `lib/ccxray.js` contracts plus the provided diff (local clone is stale; issue #543 matches the stated problem).

---

### Critical / High

#### 1. Soft failure of the shared reports poisons every pane
**Where:** `plugins/herdr/bin/refresh-all-badges.js` (shared block) + `plugins/herdr/bin/refresh-badges.js` `sharedReports()` / use site

```js
const shared = {
  status: statusReport(),   // one shot, up to 5s
  usage: usageReport(...),  // one shot, up to 12s
};
// ...
const status = shared ? shared.status : statusReport();
const usage = shared ? shared.usage : usageReport(...);

sharedReports() accepts any object with status.parsed and usage — including { ok: false, data: null }.

Before: each child independently called status / usage. A transient CLI blip on pane 1 did not force pane 2–N to “no hub / not linked”.

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 !status.ok / !usage.ok as “do not share; let child recompute” (or retry once in the parent before sharing).


2. Agent-list “once” optimization is incomplete — and the test overclaims it

Where:

  • Parent injects agent_session only (refresh-all-badges.js context object)
  • Child short-circuit (refresh-badges.js):
if (!nativeSessionId && context.agent_session?.kind === 'id') {
  nativeSessionId = context.agent_session.value;
}
if (!nativeSessionId && targetPaneId) {
  const report = herdrAgentReport({ env }); // still runs

If parent saw agent_session: null / missing / non-id, every such child still runs herdr agent list. Unlinked or pre-session panes pay the full fan-out again.

The new test uses only:

{ agent_session: { kind: 'id', value: 'sess-1' } },
{ agent_session: { kind: 'id', value: 'sess-2' } },

and asserts agent list count === 1. That does not prove the general claim in the comment (“children must reuse the parent's agent list”). It proves reuse only when every agent already has a native id.

Fix direction: share a compact pane_id → agent_session | null map (or resolved: true) in the shared file so children never re-list during fan-out.


Medium

3. Fail-open on bad shared file reintroduces the original 10s kill

Where: refresh-badges.js sharedReports() catch / validation fallthrough

If the file is missing, unreadable, non-JSON, or fails status.parsed && usage, each child re-runs statusReport (5s) + usageReport (12s) under the parent’s ~10s spawnSync cap — exactly #543.

That is intentional for the event-driven path (no env var). For the fan-out path, parent always sets CCXRAY_BADGE_SHARED_REPORT. A corrupt/partial file turns “slow but healthy” back into “timed out mid-write”, and now every child does it.

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 claim

Where: new audit: sessionSummaryDetails call sites… + docs/testing.md wording

The audit only flags call spans that literally contain the substring CCXRAY_HOME. These evade it:

const env = { CCXRAY_HOME: makeHome() }; // HOME set outside the call
sessionSummaryDetails(data, { env });   // span has no CCXRAY_HOME → skipped
sessionSummaryDetails(data, { env: pluginEnv({ CCXRAY_HOME: home }) });
// may or may not include both strings depending on inlining

Also: only scans test/herdr-plugin.test.js, not other suites; does not enforce pluginEnv() itself.

pluginEnv() defaulting CCXRAY_IMPORT_HOMES = NO_TRANSCRIPTS is the real spawn-layer fix and is good. The audit is a partial lint, not the mechanism the docs describe.


5. Residual wall-clock risk is only half-fixed (product / issue fidelity)

Where: serial loop in refresh-all-badges.js; issue #543 impact text

After the fix, worst-case per child (from lib/ccxray.js defaults) is roughly:

call budget
optional herdr agent list 1.5s
pane layout 1.2s
reportPaneTokens 2s
reportWorkspaceTokens 2s
total ~6.7s

Under 10s when shared reports work and sessions are present. Good.

Still true:

  • Parent pays up to ~17s up front before any sidebar write (status 5s + usage 12s). Time-to-first-badge can get worse.
  • Fan-out remains serial → N × child cap can still be multi-minute for many panes if herdr metadata is slow.
  • README line “each child only pays for its own sidebar writes” oversells; children still do summary/index/layout/metadata work.

Not a logic bug, but the issue’s “startup blocked for minutes” is only partially addressed.


Low

6. Shared payload is a full statusReport / usageReport blob (incl. result)

Where: parent JSON.stringify(shared)

runCommand already returns a normalized object with error, stdout, stderr, timedOut. After round-trip, error is a plain object (instanceof Error === false). Current badgeTokens only needs status.parsed and usage.{ok,data} — so this works today, but:

  • doubles usage payload (data + result.stdout)
  • freezes a brittle contract: any future consumer of status.result.error instanceof Error breaks silently
  • better share a minimal DTO: { status: { ok, parsed, text }, usage: { ok, data } }

7. Temp dir lifecycle edge cases

Where: mkdtempSync / writeFileSync / finally rmSync

Happy path is fine (serial spawnSync → child dead before next iter → finally deletes). Gaps:

  • crash between mkdtempSync and try leaks a dir
  • no mode tightening on the file (dir is 0o700 from mkdtemp, so OK on multi-user tmp)
  • empty catch {} on cleanup hides unexpected FS errors

8. Timeout classification is correct for Node, thin on tests

Where: else if (result.error && result.error.code === 'ETIMEDOUT')

Verified: Node spawnSync timeout → status: null, error.code: 'ETIMEDOUT'. Classification matches runCommand’s own timedOut field in lib/ccxray.js.

Missing coverage:

  • mixed failed + timed out summary ordering
  • non-timeout non-zero exit still labeled failed (not folded into timed out)
  • CCXRAY_BADGE_CHILD_TIMEOUT_MS parsing (< 100 → default 10000)

9. Fallback test is under-asserted

Where: refresh-badges falls back… when the shared file is bad

  • asserts stdout + that usage appears in the call log
  • does not assert status also re-ran
  • does not assert exit contract
  • no inverse unit test: well-formed shared file → zero child ccxray calls (fan-out test covers this only end-to-end)

10. Test process hygiene

makeCountingCcxray / makeFanoutHerdr / bogus shared dirs are never removed (pattern exists elsewhere in this file). Noise on long CI hosts, not a functional bug.


Test quality vs repo standards

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/usage moved to parent; child budget can fit metadata writes.
  • Event path unchanged when CCXRAY_BADGE_SHARED_REPORT unset — good.
  • usage is already called without --cwd in children; sharing one report is contract-correct for that call shape.
  • sessionSummaryDetails still 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() default CCXRAY_IMPORT_HOMES=NO_TRANSCRIPTS is the right spawn isolation fix (ambient CCXRAY_* was already stripped, so unset meant real ~/.claude*/projects).

Suggested merge bar

Request changes (or explicit follow-up commits) for:

  1. Do not share failed status/usage results (or retry); add a fail-on-old test that a parent usage failure does not force all children to skip recompute.
  2. Share full per-pane session resolution (including “no session”) so agent list is truly once; extend the count test with a null-session agent.
  3. Narrow docs/audit claims to what is actually enforced; consider fail-closed when CCXRAY_BADGE_SHARED_REPORT is 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>
@lis186
lis186 merged commit afcb214 into main Aug 17, 2026
3 checks passed
@lis186
lis186 deleted the fix/543-badge-refresh-timeout branch August 17, 2026 10:56
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