Skip to content

Mark a Herdr badge whose numbers the transcript has outrun, and rescan to fix it - #553

Merged
lis186 merged 6 commits into
mainfrom
feat/herdr-import-freshness
Aug 17, 2026
Merged

Mark a Herdr badge whose numbers the transcript has outrun, and rescan to fix it#553
lis186 merged 6 commits into
mainfrom
feat/herdr-import-freshness

Conversation

@lis186

@lis186 lis186 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

摘要

Herdr 側邊欄徽章的數字只更新到 ccxray 最後記錄的那一輪。ccxray 沒在觀測的 session(在 proxy 外啟動,或 hub 消失後續跑)會讓 transcript 繼續長、索引卻停住,於是徽章把一直在跳的 age 配上凍結數小時的 context%。

本 PR 讓徽章在磁碟上存在我們沒看到的 turn 時明示 stale、收回顏色宣稱,並觸發一次節流、加鎖、分離的重掃來修好它。

判定訊號經過兩次修正:檔案 mtime 不能用(Claude Code 會寫沒有對應 API request 的 metadata 記錄),實測 41 次發作有 37 次是誤報。改用 core 自己的 turn 定義後降到 4 次、0 誤報;tail 視窗放大後 8 次、仍 0 誤報。

What changed

Piece 1 — the marker. A session is stale when its Claude transcript holds a COMPLETED turn newer than the newest turn ccxray logged, using core's own rule (server/importer.js:165-172: an assistant record with usage, a non-zero token total, a parseable timestamp). Reading that timestamp also puts both sides of the comparison on the same clock — it is the field the importer stores as receivedAt.

When it fires, ctx_bar's signal slot reports stale 11h, the summary appends the same, ctxBand drops to unknown, and the ctx token gets a stale suffix. The percentage itself stays.

Two things it deliberately does not use:

  • Elapsed time alone. A finished session and a user who stepped away are equally quiet, and their numbers are still correct. Over the full index an elapsed-only rule fires on 4467 of 4470 sessions.
  • The transcript's file mtime. Claude Code appends system, last-prompt, mode, permission-mode and file-history-snapshot records with no API request behind them, so mtime advances on a session ccxray is watching perfectly. Measured over 161 locatable real sessions, an mtime rule fired on 41 — 37 of them false, including both sessions that first looked like proof of the bug.

Piece 2 — the rescan. ccxray import --once is a throttled (10min), lock-guarded, single-shot transcript scan the badge fires detached. It does NOT refuse while a hub is running, unlike rebuild-index --reimport: that refusal is right for a destructive full rebuild and would leave every hub user permanently stale here. What made the refusal necessary is avoided instead — this process only appends index lines and never writes sessions.json, because session-index.js's tmp file has a FIXED name, so tmp+rename protects against a crash but not against a second writer. ADR 0019 records that contract; the hub re-derives via the existing "index.ndjson newer than sessions.json" rebuild.

Evidence

Detection, on the real index. 194 locatable sessions in a 60MB slice; the detector fires on 8, and every one verifies against an independent signal — a 100%-imported index whose transcript holds 63 to 639 more completed turns than ccxray ever logged. Zero false positives. One is a session that was running on this machine at review time, 308 turns behind.

Coverage, disclosed rather than glossed. About 40% of sessions have no locatable transcript (codex panes, a cwd ccxray never recorded) and are never marked. The bias is deliberately toward silence.

Differential, taken in throwaway worktrees per docs/verification-principles.md rather than by checking files out over a dirty tree:

  • baseline origin/main in a sanitized env (env -i): 2157/2157, 4 runs, 0 flakes
  • origin/main + only the new test files: 12 behavioural tests fail, the guards pass
  • the lock race test on 44989a0: fails at round 0 with two owners; passes on 560a16f
  • branch: 2184/2184, twice, with an empty CCXRAY_HOME

Test isolation. Staleness reads a scan root outside CCXRAY_HOME (the ADR 0015 R4 class) and runs in-process, so a throwaway $HOME is not available. Every call site pins CCXRAY_IMPORT_HOMES; an instrumented run confirms reads of the developer's real ~/.claude* went 7 → 0. docs/testing.md records the requirement and the fixture shape.

Review gate

codex was unavailable (usage limit, resets 2026-08-20). Per owner instruction the gate ran on grok, which returned DON'T-SHIP on the lockfile.

It was right. wx-else-unlink-and-retry admits two owners: both racers see the same stale lock, both unlink, the second removes the first's fresh lock. The first fix — hardlink for atomic create, inode identity for ownership — was still wrong, and the new 8-racer test found two owners in round 2 under load: stat and unlink are separate syscalls, so R1 can stat the stale inode, R2 can replace it with its own live lock in between, and R1's unlink removes R2's. The takedown is now serialized by a second hardlink, and the residual (a process dying inside the reclaim) is documented at the site.

Also from that review, each fixed here: the mtime gate rested on a false claim about mtime never preceding file content (a restore or a touch breaks it in the dangerous direction); the 512KB tail window hid true positives (4MB now — firings went 4 → 8, all verified); importOnce now refuses an injected env it cannot honour; transcriptSlug normalizes // and a trailing /; the slug test built its fixture with the production regex so it would have passed for any rule; --force had lost its dedup coverage to a lock probe.

The ccxray import --once CLI is new and user-facing; plugins/herdr/README.md and the CLAUDE.md architecture table document it, along with CCXRAY_BADGE_STALE_MS and CCXRAY_BADGE_IMPORT_DISABLE.

🤖 Generated with Claude Code


Correction — the race test was not evidence

The fix(herdr): close the lock race commit claimed its 8-racer test fails on the pre-fix lock "at round 0". It did, twice. Re-running it on an idle machine gives three consecutive passes on the buggy code; 8×2, 8×3, 12×2 and 16×1 all pass too. The interleaving it depends on needs contention to appear, so its sensitivity tracked machine load — the runs that "proved" the fix happened while a wedged suite and eight unkillable orphan processes were competing for CPU.

It was also expensive in a way that showed up as someone else's failure. Measured in a herdr pane, this branch failed 2 unrelated process-timing e2e tests per run (a different pair each time) against 2157/2157 on origin/main under identical conditions. Shrinking the test to 6×2 made it cheap and completely blind.

Replaced by an assertion on the mechanism rather than the symptom: plant a stale lock, plant a .reclaim held by a live pid, require that acquireLock neither acquires nor touches the stale lock — with a companion test that the same state minus the live reclaimer does proceed, so the guard is a gate and not a permanent refusal. The pre-fix lock has no .reclaim concept and reclaims regardless: 3/3 FAIL on 44989a0, 3/3 PASS after, with no timing dependence.

Pre-merge review (fable) — items addressed

  • origin/main had moved two commits ahead and the branch conflicted on CLAUDE.md. Merged; the conflict resolves to the rewritten architecture table (which already carried the import-once.js row) plus the ADR 0019 invariant line. Verified nothing of the concurrent export-sync.js work was swept in — the merge commit touches it not at all.
  • Comments citing superseded measurements: the tail-bound discussion still said 512KB and "61 of 161" seventeen lines below a 4MB constant, and evidenceStaleness's header still said "4 of 161 / 192 to 752" while its own body said 194. Each now states the bound it was measured at.
  • filesSkippedduplicatesSkipped: scanAndImport's skipped counts already-indexed turns, not files, and this is a machine-readable CLI field.
  • Dropped evidenceStaleness and WATCHDOG_MS from module exports — nothing imports them.
  • ADR 0019 now records the case the env guard does not cover: a caller passing process.env itself is allowed through and keeps the flush guard set on its own process.

Full suite on the merge result: 2185/2185, empty CCXRAY_HOME.

Rendered-sidebar verification (owner, 2026-08-17)

Done, as an A/B with a negative control rather than a single confirming look. A
synthetic session was pushed to one pane twice, the two runs differing only
in the timestamp inside a throwaway transcript, so a marker that fired regardless
of staleness would have shown up identically in both:

ctx row as drawn summary row as drawn
A transcript level with the index 33% · claude-opus-4-6 · $1.23 opus-4-6, 4.0h, $1.23
B transcript 4h ahead of the index 33% stale · claude-opus… · $1.23 opus-4-6, 4.0h, $1.23 …

A shows no marker, B does. The summary differs too — B is truncated because
· stale 4.0h was appended past the column width.

That also corrects something stated earlier in this PR. The summary was described
as unreachable on a sidebar whose rows do not name $summary; it reaches the
screen anyway, because refresh-badges.js passes it as every state's
--state-label, and that config's first row does name state_text. Two of the
three channels are therefore live on a minimal layout, not one.

The verification touched no real data and needed no reinstall: fixtures were
temp dirs, CCXRAY_BADGE_IMPORT_DISABLE=1 suppressed the rescan, and the tokens
carried a TTL so herdr dropped them on its own.

Still open

  • The ctx_bar colour claim is unverified. ctx_band is asserted to flip to
    unknown at the token layer and install-sidebar-summary.js maps that to
    #a6adc8 against #a6e3a1 for green, but the sidebar used for verification
    does not reference any $ctx_bar* token, and rewriting a hand-written config
    to see one colour was not judged worth it.
  • codex review gate not run (usage limit until 2026-08-20). grok stood in per owner instruction and returned DON'T-SHIP on the lock; that finding is fixed above.

Justin Lee and others added 6 commits August 17, 2026 11:40
繁中摘要:herdr 側邊欄徽章的數字只更新到 ccxray 最後記錄的那一輪。
ccxray 沒在觀測的 session(在 proxy 外啟動,或 hub 消失後續跑)會讓
transcript 繼續長、索引卻停住,於是徽章把一直在跳的 age 配上凍結數小時
的 context%。本次讓徽章在磁碟上存在我們沒看到的 turn 時明示 stale,並
收回顏色宣稱。

The badge is only as fresh as the newest turn ccxray logged, so a session
it stopped observing renders a live-ticking age beside numbers frozen
hours ago — the reported case being a transcript at 89% of a 1M window
still showing 35%.

Elapsed time alone cannot carry this: a finished session and a user who
stepped away are equally quiet, and their numbers are still correct. Over
the full index an elapsed-only rule fires on 4467 of 4470 sessions, which
is the same as not firing at all.

Neither can the transcript's file mtime. Claude Code appends `system`,
`last-prompt`, `mode`, `permission-mode` and `file-history-snapshot`
records with no API request behind them, so mtime advances on a session
ccxray is watching perfectly. Measured over 161 locatable real sessions,
an mtime rule fired on 41 — 37 of them false, including both sessions
that had first looked like proof of the bug.

What proves a miss is a COMPLETED turn newer than our newest evidence,
using core's own rule (server/importer.js:165-172): an assistant record
with usage, a non-zero token total, and a parseable timestamp. Reading
that timestamp also puts both sides on the same clock — it is the field
the importer stores as `receivedAt`. It fired on 4 of the 161, each
confirmed by an independent signal: all four carry a 100%-imported index
whose transcript holds 192 to 752 more completed turns than ccxray ever
logged.

When it fires, ctx_bar's signal slot reports `stale 11h`, the summary
appends the same, and ctxBand drops to `unknown` so the neutral
`ctx_bar_unknown` token replaces a green that was asserting safety about
a number four hours out of date (owner decision). The percentage itself
stays. This is the channel-inverse of ADR 0013's provenance markers,
which mark the number and keep colour as saturation; the badge has no
room for a marked number, and the comment says so rather than claiming
that ADR endorses it.

The cwd→slug rule flattens every non-alphanumeric, derived by replaying
all 118 cwds in the real index against the 184 project directories on
disk: 43 reproduced versus 41 for a '/'-and-'.'-only rule. The two it
recovers are real — a cwd with '_' and a worktree branch with '+' — and
each had made the feature silently inert for that project (+3 sessions
located, 0 regressions).

A lookup that cannot find a transcript stays silent rather than guessing;
codex panes and any session whose cwd was never recorded fall here, about
40% of the corpus. That is a deliberate false-negative bias.

Verification (docs/verification-principles.md), differential in both
directions: against pre-fix main, 3 tests fail (the feature is absent);
against the file-mtime implementation this replaces, 3 fail (the
metadata-only false-positive class, and the slug rule). Four guard tests
pass on both sides and are labelled as guards, not as evidence. Full
suite 2166/2166 with an empty CCXRAY_HOME.

Isolation: staleness reads a scan root outside CCXRAY_HOME (the ADR 0015
R4 class) and runs in-process, so a throwaway $HOME is not available.
Tests pin it with CCXRAY_IMPORT_HOMES, the knob core's importer honours;
docs/testing.md records the requirement and the fixture shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
繁中摘要:讓剛標記為 stale 的徽章觸發一次節流、加鎖、分離的
transcript 重掃——標記的條件正好就是重掃能修好的條件。這個指令不會在
hub 運行時拒絕執行(那會讓所有 hub 使用者永遠 stale),改為避開真正的
危險:只 append 索引行、完全不寫 sessions.json。

The staleness marker doubles as the trigger: it fires precisely when
completed turns are sitting on disk that ccxray never logged, which is
what a rescan fixes.

`rebuild-index --reimport` refuses while a hub is running
(rebuild-index.js:573-577). That is right for a destructive full rebuild,
but applying it here would leave every hub user permanently stale — the
failure being fixed. What made the refusal necessary is avoided instead.
The hazard is specific: session-index's tmp file has a FIXED name
(session-index.js:59), so "atomic" holds against a crash, not against a
second writer — one process can rename another's half-written bytes. So
this command appends index lines (O_APPEND, storage/local.js:91-93) and
sets CCXRAY_SESSION_INDEX_NO_FLUSH=1 to leave the derived view alone.
Nothing is lost: loadSessionIndex already rebuilds when index.ndjson is
newer than sessions.json (:68-72), which is exactly the state an append
leaves behind. The badge reads index.ndjson directly and never reads
sessions.json, so this is also sufficient.

Divergence from the work order, stated rather than silently applied: it
proposed gating on "index.ndjson unchanged since the last import". That
test is inverted for this caller — the badge fires BECAUSE the index
stopped growing, so the gate would suppress every run that mattered. A
plain time throttle is used (10 min, CCXRAY_IMPORT_ONCE_MIN_INTERVAL_MS),
and the index mtime is recorded for diagnosis only.

The three failure modes a detached child has to answer, each checked by
running it, not by reasoning about it:

- duplication: two concurrent --force runs produced one import of 8 turns
  and one {"ran":false,"reason":"locked"}; the index got 8 lines, not 16.
- orphan: no leftover lockfile and no leftover process; the lock is
  reclaimed when its owner is dead (kill -0, not ps|grep) or older than
  its TTL, and an unref'd watchdog bounds a scan that wedges.
- silent death: the first version returned {"ok":true} and exit 0 on an
  unwritable CCXRAY_HOME — a failure indistinguishable from a throttle
  skip, to a caller that reads neither. Only 'locked' may report success
  now; every other lock failure exits non-zero and records lastError.
  Regression test included.

Plugin side spawns detached + unref'd + stdio ignored and never awaits;
the test asserts the call returns in under 300ms while the child keeps
running. CCXRAY_BADGE_IMPORT_DISABLE=1 keeps the marker and stops the
rescan.

Tests: 11 new in test/import-once.test.js (import, no-sessions.json,
throttle, --force, post-window rerun, loud lock failure, unknown mode,
four locking cases, and the session-index flush guard asserted where it
lives), 2 new in the plugin suite. Full suite 2179/2179 with an empty
CCXRAY_HOME.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n cost

繁中摘要:補上先前只做了一層的 ADR 三層。`session-index.js` 的
`INVARIANT` 註解原本是全檔唯一沒有 ADR 編號的一條,破了 repo 慣例。
同時降低 import-once 測試的子行程數,並修掉一個會讓差異證據縮水的
describe-body require。

The `CCXRAY_SESSION_INDEX_NO_FLUSH` guard added with `import --once` is a
cross-file contract with a silent failure mode: delete the check from
`flush()` and the fixed-tmp-name race returns, observable only as an
occasional unexplained sessions.json rebuild. It was carrying a code
comment and nothing else — the only `INVARIANT` in session-index.js
without an ADR number behind it, against a file where every other one
names 0013, 0016, 0017 or #503. ADR 0019 completes the three layers
(comment, CLAUDE.md entry, decision record) and states the two properties
that make appending-without-deriving sound rather than merely convenient:
O_APPEND at line granularity needs no coordination, and the derived view
is rebuildable with staleness already detected.

Test changes, both found while replaying the differential on a throwaway
worktree rather than by toggling files in a dirty tree:

- The `locking` describe required `../server/import-once` in its describe
  BODY. Against a build without the module that throws at registration, so
  its four subtests never register at all instead of failing — the replay
  showed 2175 tests where 2179 were expected, quietly shrinking the
  differential by four. Moved into each test.
- Merged the index-lines and no-sessions.json assertions into one test.
  Both described the same single run, and each spawn is a full
  `node server/index.js`; the suite runs files in parallel and one replay
  showed a timing-sensitive websocket test timing out alongside them.
  Spawn count is a cost, not free coverage.

Verification: baseline origin/main in a sanitized env (`env -i`) is
2157/2157. With only the new test files copied in, 12 behavioural tests
fail and the 4 guards pass — the differential, taken in a throwaway
worktree per docs/verification-principles.md rather than by checking files
out over a dirty tree. Branch is 2178/2178.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e2e tests

繁中摘要:我引入的測試回歸。基準 0/4 次 flake,加上我的測試後 6 次有 2 次
讓既有的時間敏感 e2e 測試逾時。把不需要真的執行匯入的測試改成 in-process
(它們在觸及 importer 前就回傳),spawn 從 9 降到 5。

Measured, not assumed. `node --test test/*.test.js` runs files in parallel,
and two pre-existing e2e tests wait on hardcoded deadlines —
websocket-proxy.test.js:50 (8s "timeout waiting for proxy") and
hub-client-signal.e2e.test.js (500ms status, 3s exit). Each runImport() is
a full `node server/index.js`.

  origin/main, sanitized env, 4 runs   → 2157/2157 every time, 0 flakes
  with this file at 9-10 spawns, 6 runs → 2 runs saw one of those two fail
  with this file at 5 spawns, 3 runs    → 2178/2178 every time

The throttle skip and the lock failure both return before the importer is
ever required, so they need no child process at all; they now call
importOnce() in-process with an injected env, which touches neither
process.env nor CCXRAY_HOME. `--force` keeps its coverage without a scan:
holding the lock first means reaching 'locked' proves the throttle check
was already passed, which an unforced call in the same state cannot do.

Three post-reduction runs is not proof the flake is gone — it is a small
sample against a低-rate event, and the honest claim is that the load that
plausibly caused it is down by nearly half with coverage intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
繁中摘要:codex 額度用盡,review gate 改用 grok。它判定 lock 為 blocking,
而且判斷正確——兩個 racer 可以同時取得同一把鎖。本次連同它找到的 mtime
gate 假設、tail 視窗漏報、env 陷阱、以及測試品質問題一併修正。

## The lock (grok: blocking) — took two attempts, and the test caught the first

`wx`-else-unlink-and-retry admits two owners: both racers see the same stale
lock, both unlink, the second removes the first's fresh lock, and both proceed
to `scanAndImport` with `existingIds` loaded before either appends.

The first fix — hardlink for atomic create, inode identity for ownership,
and an inode check before the reclaim unlink — was still wrong, and the new
8-racer test found two owners in round 2 under load. `stat` and `unlink` are
separate syscalls: R1 stats the stale inode, R2 replaces it with its own live
lock in between, R1's unlink removes R2's. Narrowing a check-then-act window
is not closing it.

The takedown is now serialized by a second hardlink. Only the holder of
`.reclaim` may delete a lock, and it re-reads the holder while holding it, so a
lock that became live in the meantime survives. Residual, documented at the
site: a process dying inside the reclaim leaks `.reclaim`, and the bound that
clears it is itself check-then-act — microseconds against 60s, and its failure
mode is the original race rather than a new one.

Also from the same finding: a live owner is no longer displaced by age. The old
TTL treated a scan running past 5 minutes as abandoned while the throttle
allowed a new run at 10, so a slow-but-working import was reclaimed and
double-imported. The age bound now exists only for pid reuse (1h), and the
watchdog is decoupled from it (30min) so it bounds a wedge instead of killing a
working scan mid-append.

## The mtime gate was resting on a false claim

The comment said mtime is never older than any record inside the file. `cp`
without `-p`, a restore, a sync-client rehydrate, a `touch`, or a clock set back
all break it in the dangerous direction: mtime behind content that is hours
ahead of the index — the exact session the marker exists for, skipped. The
trusted band is now symmetric, and measured on 194 real locatable sessions that
costs **0 extra file reads**: 34 sessions sit slightly behind for ordinary
reasons and stay inside the band.

## The tail window was hiding true positives

512KB was too small: a missed turn followed by more metadata than that, or one
oversized assistant line, put the newest turn outside the read. Raised to 4MB.
On the real corpus the detector goes **4 → 8 firings**, and all 8 verify against
an independent signal — every one has a 100%-imported index whose transcript
holds 63 to 639 more completed turns. Zero false positives. One of them is a
session running on this machine right now, 308 turns behind.

## Smaller ones

- `importOnce` refuses to run the scan under an injected env: importer.js reads
  `process.env` directly and `config.LOGS_DIR` is fixed at first require, so
  honouring one would scan the wrong homes and leak the flush guard.
- `transcriptSlug` normalizes `//` and a trailing `/` — same directory, different
  slug, silent miss.
- `$ctx` carries the state too. The marker lived only in `summary` and the
  ctx_bar colour band, and a sidebar row shows a token only if it names it; a
  real config in the field names neither, so the whole marker rendered nowhere.
- Test isolation: staleness made every pre-existing `sessionSummaryDetails` test
  stat the developer's real `~/.claude*` (measured: 7 accesses per call). It
  passed only because no real transcript is named `s1.jsonl` — the #407 shape.
  All call sites now pin an empty root; an instrumented run confirms 7 → 0.
- The slug test built its fixture with the production regex, so it would have
  passed for any rule including a wrong one. Expected directory names are now
  written out literally.
- `--force` had lost its dedup coverage to a lock probe; both are asserted now.

Verification: the race test fails on the pre-fix lock at round 0 (two owners)
and passes after — taken in a throwaway worktree at 44989a0. It is deterministic
by construction: file barriers to start AND to stop, because a winner that exits
while a starved sibling is still running frees its lock, and reclaiming a dead
owner's lock is correct rather than a violation. Full suite 2184/2184 twice with
an empty CCXRAY_HOME, race test included under parallel load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…istic one

繁中摘要:merge 上游(CLAUDE.md 架構表衝突已解,三邊內容都在),並處理
fable merge 前檢查的項目。最重要的一項是我自己回頭驗出來的:那個 N-racer
測試是機率性的,在閒置機器上完全重現不了 bug——等於證據無效。改成直接
斷言修正機制、確定性的測試。

## The race test was not evidence

`fix(herdr): close the lock race` claimed the 8-racer test fails on the pre-fix
lock "at round 0". It did, twice. Re-running it on an idle machine: **three
consecutive passes on the buggy code**, and 8x2 / 8x3 / 12x2 / 16x1 all pass
too. The interleaving it depends on needs contention to appear, so its
sensitivity tracks machine load — the earlier runs happened while a wedged suite
and eight unkillable orphans were competing for the CPU.

A detector that only fires under load is a coin flip, and this one also spawned
40 processes into a parallel suite: measured in a herdr pane, my branch failed
2 unrelated process-timing e2e tests per run (different ones each time) against
**2157/2157 on origin/main under identical conditions**. Shrinking it to 6x2
made it cheap and completely blind.

Replaced with an assertion on the mechanism: plant a stale lock, plant a
`.reclaim` held by a live pid, and require that `acquireLock` neither acquires
nor touches the stale lock — with a companion test that the same state minus the
live reclaimer does proceed, so the guard is a gate rather than a refusal. Old
code has no `.reclaim` concept and reclaims regardless. **3/3 FAIL on 44989a0,
3/3 PASS after**, no timing dependence.

## From the pre-merge review

- `origin/main` had moved two commits (bd726f2 rewrote the architecture table,
  b277db2). Merged; the CLAUDE.md conflict resolves to the rewritten table, which
  already carried the `import-once.js` row, plus the ADR 0019 invariant line.
- Comments citing superseded measurements: the tail-bound discussion still said
  512KB and "61 of 161" seventeen lines under a 4MB constant, and
  `evidenceStaleness` still said "4 of 161 / 192 to 752" while its own body said
  194. Both now carry the bound they were measured at.
- `filesSkipped` renamed `duplicatesSkipped`: `scanAndImport`'s `skipped` counts
  already-indexed turns, not files, and this is a machine-readable CLI field.
- Dropped `evidenceStaleness` and `WATCHDOG_MS` from exports — nothing imports
  them.
- ADR 0019 records the latent case the env guard does not cover: a caller that
  passes `process.env` itself is allowed through and keeps the flush guard set.

Full suite 2185/2185 on the merge result with an empty CCXRAY_HOME.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lis186
lis186 merged commit 1211dc3 into main Aug 17, 2026
1 of 3 checks passed
@lis186
lis186 deleted the feat/herdr-import-freshness branch August 17, 2026 09:12
lis186 added a commit that referenced this pull request Aug 17, 2026
…E3 isolation audit (#543) (#557)

* fix(herdr): run the fan-out's pane-independent reports once, report killed 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>

* test(herdr): enforce CCXRAY_IMPORT_HOMES pinning as a mechanism, not 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>

* fix(herdr): share only usable reports; skip agent re-list for session-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>

---------

Co-authored-by: Justin Lee <justinlee@91app.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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