home: bounded board reads — session-boundary indexes and per-file reading cache (#62) - #78
Conversation
a53bd7f to
ec783a5
Compare
|
Pushed a fix round addressing round-1 review (3 P1, 3 P2, plus P3-1/P3-2/P3-5/P3-6): live-row guessing and cross-project resume lookup restored (snapshot-first, live-scan fallback on a genuine miss, never disabled outright), a render-time self-heal for a missing/stale per-tank snapshot (fixes the permanently-empty Resume section and frozen fuel readings for tanks that never hit a session boundary), Rebased onto current |
A lane that reaches for the Agent tool hands the whole task to a background sub-agent and ends its own turn; `claude -p` then terminates the run — sub-agent included — after its background wait ceiling, and nothing reaches disk. Measured twice (2026-09-10; 2026-09-13 on the #78 fix lane: 651 s, empty worktree). A brief saying "no sub-agents" is a reminder; this is the guard. burn now appends `--disallowedTools Agent,Task` to every print-mode claude argv that carries no tools flag of its own — composed recipe, raw `--` form, and again after a cross-engine reroute lands on claude — and exports CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 unless the operator set it, so a run that still spawns background work is waited for rather than killed. Other engines are untouched. Co-authored-by: cverorg <292680828+cverorg@users.noreply.github.com>
|
Fix round 2 pushed, addressing the round-2 adversarial review's 3 P1s and 2 P2s (P1-A/P1-B/P1-C, P2-A/P2-B), plus P3-1/P3-4.
Every fix has a deterministic before/after receipt. Full |
…eading cache (#62) The board no longer walks transcript trees on every render. Session discovery goes through per-tank boundary indexes (claude, codex, agy, grok), recent candidates are capped per cwd and tank (CLIKAE_HOME_RECENT_MAX, default 10), and every per-file parse (title, recap, limit, success, agy email) is cached by kind+path+size+mtime, including negative readings. Snapshots are published through unique mktemp files so concurrent renders never read a half-written index. Built by codex (gpt-6-astra) on tank crazy in two runs; report in the lane worktree (REPORT-board62-build.md, not committed). by KITT
…r time Round-1 fix review, P1-2/P1-3: a board snapshot only got (re)built at a session boundary (clikae run/burn/agy's own switch), so it went stale the moment anything reached an engine without passing through one of those three call sites (clikae alias, clikae env, a .app bundle, relay, switch.sh's ephemeral path) - permanently, for that tank, with no way to self-correct. It also froze fuel (dry/limit) readings behind an arbitrary "snapshot published within N seconds" age gate that was never a proxy for "the underlying data is still accurate". board_generation now does one cheap freshness check per read, per tank (a directory mtime plus a bounded handful of file mtimes, both compared for equality against what board_state_refresh recorded at publish time - never ">" against a wall-clock stamp, which would read a future-dated fixture or a clock-skewed file as permanently stale) and rebuilds inline, only for the one tank actually being read, the instant that check disagrees with what was last published. board_read/board_recent/board_find all route through it, so limit.sh's age-gate heuristic is no longer needed and is removed. Also verifies board_key (a 32-bit cksum) lookups against the raw sid/scope recorded at publish time, so a hash collision reads as a miss rather than silently answering with a different session's data - the same guard reading_cache_run already uses for its own key. board_read gains an explicit <engine> argument (board_generation needs it to know which scan root/scope rules apply); its handful of callers in limit.sh and scan.sh are updated accordingly.
Round-1 fix review, P1-1: board mode disabled the whole tank-scoped title guess for an unstamped live row, and the all-projects glob fallback for a stamped row's sid outside the current PWD's project slug - not narrowing either one, turning both off outright. Live rows are bounded by how many tmux sessions exist, not by transcript count, so neither one was the O(all transcripts) cost issue #62 was written to kill. Each adapter's adapter_recent_sids/adapter_find_session (and codex/grok's _codex_find_rollout/_grok_find_summary) now try the snapshot first - cheap, and self-healing as of the previous commit - and only fall through to the original live scan when the snapshot itself has nothing to say for this exact query, never disabling the scan wholesale. home.sh's own _CLIKAE_BOARD gates around the guess passes are removed to match: the adapter functions already make the cost-vs-cost call internally now.
Round-1 fix review, P2-1/P2-2: cmd_run and the agy switch used to wrap adapter_run/exec agy in a subshell so a board_state_refresh could run before and after the engine - which meant clikae stayed a resident parent for the whole session instead of exec'ing away (different signal delivery, $PPID, and a 128+N exit code instead of WIFSIGNALED when the engine is killed), and paid a full tank scan synchronously on every interactive launch and every exit. Neither refresh is needed anymore: board_generation (previous commit) rebuilds a stale tank inline the moment a render actually reads it, so there is nothing left for a boundary call here to buy. Both call sites go back to a bare tail call/exec, matching main.
… precision Round-1 fix review, P2-3: state/board generations and state/readings entries grew unbounded and clikae clean never touched either. board_state_refresh already GC's its own tank's generations on every publish now (board_gc_generations, keeping the newest few by mtime instead of an mtime+1-day floor); clikae clean additionally sweeps state/board for tanks that have not been launched (and so never re-published) in a while, and state/readings for entries whose own source file no longer exists. P3-1/P3-5: reading_cache_run's key only had whole-second mtime precision, so a same-size overwrite within the same wall-clock second could read as a cache hit. Its stat call now asks for GNU's fractional-seconds modifier / BSD's F sub-format, detected once via the same _clikae_statv this repo already uses elsewhere (never `stat -c ... || stat -f ...`: on a GNU machine `-f` means --file-system and prints something else entirely, which is exactly what _clikae_statv's own comment warns against).
- home-bounded.bats: the "missing state never triggers discovery" test encoded the P1-2 bug (an unpublished tank stays empty forever) as the expected behaviour; rewritten to assert the self-heal instead (discovers once, then reads bounded again). The "live rows read only the stamped transcript" test likewise encoded the P1-1 bug (an unstamped row never guesses in board mode); rewritten to assert the guess reads at most the one candidate it settles on. The run.sh test updated to assert cmd_run no longer calls board_state_refresh at all (exec semantics restored). - home.bats: "bare clikae changes nothing on disk" now excludes state/ from the before/after comparison - the board cache's own inline self-heal is a deliberate write to clikae's OWN derived cache, not to session/profile source data, which is what the invariant actually protects.
board_stale (board_state.sh) compares this against what it recorded at publish time to decide staleness. Whole-second precision made any write landing in the same wall-clock second as the last publish invisible in either direction: a limit landing mid-second read as fresh, and a resolved limit stuck reading stale. _reading_cache_keyv already made this exact fix for its own cache key; this brings files_mtime_size in line with it.
Rollouts live three levels under sessions/ (sessions/YYYY/MM/DD/), so sessions/ itself never took a new session's mtime as its own dirent count didn't change. This adds a helper that resolves the deepest EXISTING directory on today's date path (walking up from DD to MM to YYYY to sessions/ itself) so board_state.sh's freshness check has a directory whose mtime genuinely moves when a new rollout appears today, whether it's the first session of an existing day (bumps the DD dir) or the first of a new day (bumps whichever ancestor already exists). No `find`: this runs on every render via board_stale, and this repo's own test suite hard-fails any find call on a warm render.
P1-A: recent/<key> rows now carry a size column alongside the nanosecond-precision mtime (files_mtime_size), and board_stale compares both. Previously it kept only a whole-second mtime and threw away the size files_mtime_size already handed back, so a write landing in the same wall-clock second as the last publish was invisible either way. board_recent still hands callers the older, public whole-second "<mtime>\037<sid>" contract (a separate display-mtime field, never the staleness one) so home.sh's _human_age arithmetic keeps working. P1-B: claude's fuel reading (claude-usage) scans projects/ in full — it's account-level — but the only freshness signals were PWD-scoped, so a limit landing in a different project directory never invalidated this one's board. board_state_refresh now also records projects/'s own mtime (catches a brand new project directory) and the (mtime, size) of every file that was inside the -mmin -300 window at publish time (catches an append to a session in a project that already existed) — both O(bounded), never a tree walk. _claude_usage_stale checks these BEFORE board_stale's per-scope early return, so a scope with no recorded recent sessions still answers for an account-level limit. P1-C: _board_scan_root's codex case now points at _codex_today_scan_dir (see its own commit) instead of sessions/ itself. P3-1: board_gc_generations' sort now breaks ties on directory name. Several publishes inside the same wall-clock second all get whole-second mtimes, which made the sort's outcome non-deterministic (mktemp's XXXXXX suffix is random, so this doesn't guarantee `current` survives a prune, but it does make the same disk state sort the same way every time). P3-4: load_adapter now runs before mktemp -d, not after — a load failure no longer leaves an orphan generation directory behind.
…sonl The reading_cache identity used to be the shared history.jsonl, not the one session's own transcript file — so any write to history.jsonl (one new agy session anywhere) invalidated every OTHER session's cached cwd at once. On a synthetic 500-session tank, adding one new session turned into a 500-entry cache stampede: board_state_refresh went from ~5.2s (history.jsonl unchanged) to ~9.9-10.1s (one new line), because 499 unrelated sessions each re-paid a grep over history.jsonl they didn't need to. Keying on the session's own transcript file means an unrelated session's cache entry survives a write elsewhere; only the session whose own transcript actually changed re-derives its cwd. Measured before/after with board_state_refresh timed directly on the same 500-session store: refresh cost after adding one session drops from ~9.9s back to the ~5.2s unchanged baseline.
run.sh and antigravity.sh already dropped their boundary-call board_state_refresh invocations in round-1, for the reason recorded in run.sh's own comment: board_generation now rebuilds a stale tank inline, right when a render actually reads it, so a boundary call buys nothing but a synchronous full tank scan. burn.sh's two call sites (one per attempt, before launch and again before classifying the reply) were left in place and the round-1 fix report never mentioned them. Measured directly: one claude board_state_refresh on a 50-session store costs ~800ms; the agy equivalent on a 500-session store costs 4.7-10s (see the antigravity.sh commit). burn was paying that twice, unconditionally, per attempt.
claude's account-level fuel freshness check now costs two more bounded stat calls per render section (projects/'s own mtime, plus one batched files_mtime_size over the -mmin -300 file set) — still O(one freshness check per render section), never O(transcripts), but board_generation's per-process cache does not survive a command-substitution subshell boundary, so a real render pays this per section. Measured: 165 lines on the existing fixture, up from a ceiling tuned to 160 before this signal existed. The equal-cost-at-100-vs-1000-transcripts assertion (the actual boundedness claim) is unchanged and still passes.
…ad-only boundary Rebased onto origin/main (ab6fb52): PR 80's antigravity title lookup now wraps in the cached-title path instead of being replaced by it, and the two textual home.bats conflicts kept main's idiom. PR 79 (dry-reset-expiry) landed a third return code on _limit_codex_dry: rc=2 is positive recovery evidence, distinct from rc=1 (nothing found). This PR's own codex-snapshot test predates that change and still expected rc=1 for a later success; update it to assert rc=2, matching the contract PR 79 established. Ruling: a bare render MAY write its own derived cache under CLIKAE_HOME/state/ - never under any tank's profile dir. The read-only test now asserts exactly that boundary: profiles/ (the tank's source data) is byte-for-byte untouched by a bare render; state/ is excluded from the comparison because it is allowed, not required, to change.
_claude_usage_stale had two signals: projects/'s own mtime (a brand new project directory) and the recorded (mtime, size) of each file that was inside the -mmin -300 window at the last publish. Neither sees a brand new FILE landing inside an EXISTING project directory: creating a file does not move projects/'s own mtime, and the file was never in the -mmin -300 set at the last publish (it did not exist yet), so there was no per-file record to re-stat either. Once that happened the account's claude tank stayed green forever, with no timeout to recover it. Record each existing projects/<slug> directory's own mtime too, bounded by the project count, folded into the same files_mtime_size call as the tracked files (one fork, not two) so a new file always moves a signal this check is watching. Separately, an empty tracked-file set at publish time used to read as "nothing to compare against, call it fresh" - permanently, since an append to an OLD file (already excluded from the -mmin -300 window) does not move any directory's mtime either. Zero evidence is not evidence of freshness: that exit now rebuilds instead.
…clock _codex_today_scan_dir computed "today" from the OBSERVER's own `date`, then walked up to the deepest existing ancestor of that path. This is only correct when the observer's local "today" agrees with whatever clock actually wrote the newest rollout's date directory - true by default (codex uses the machine's own local time), false the moment clikae runs under an explicit TZ, across a timezone during travel, or under a CI/cron invocation pinned to a different zone than the interactive session that wrote the rollout. When the two disagree, the old code settled on a directory that could be a SIBLING of where the real newest rollout landed; a new rollout inside an existing sibling day directory only bumps that day directory's own mtime, never its parent's, so the freshness check watched the wrong thing and the board never rebuilt. Replace it with _codex_newest_chain, which reads the actual newest YYYY/MM/DD chain off disk (bounded glob at each of three levels, no `find`) instead of computing a path from the wall clock. board_state.sh generalizes _board_scan_root and its scanroot-mtime bookkeeping from a single path to a list, batched through one files_mtime_size call, so codex's multi-level chain and the other three engines' single path share the same publish/check code. Receipt (probe-p21.sh): a rollout in an existing sessions/2026/09/12 directory, rendered with TZ=Asia/Tokyo throughout (observer's own "today" is already 2026/09/13) - the old code left the tank frozen fresh; the new code sees it on the next render, same as TZ=UTC (where the fixture's date agrees with the observer's).
antigravity records cwd IN the file (history.jsonl), not in the path, so board_state_refresh's rebuild loop scans the WHOLE account's sessions on a genuine miss, never just this PWD's. Deriving each session's cwd used to cost one reading_cache_run call - a stat, a cksum, and a subshell read even on a cache HIT - per session. On a synthetic 500-session tank that measured as a ~5s fixed cost on top of whatever a real rebuild needs, standing between the round-2 fix (the cache-key stampede is gone) and beating main's per-render cost on the very first render after any change on the tank. Add adapter_session_cwd_index: one awk pass over history.jsonl building the whole sid -> workspace map at once. board_state_refresh's rebuild loop and adapter_recent_sids's discovery fallback both use it when available, falling back to the old per-file adapter_session_cwd otherwise (a test's minimal stub adapter, or a session missing from the index). Same source of truth, same "first occurrence per sid wins" semantics as the per-file lookup - this removes the per-session forks around an unchanged read, not the read itself. Receipt (perf-p22.sh, the round-3 review's synthetic shape: 500 agy + 50 claude sessions, ~103KB each, one cwd): first render after adding one agy session with a limit line - before this fix, ~7.1s; after, ~3.7-3.9s, matching main's own ~3.6-3.9s baseline on the same store.
…n GC sorts P3-1: P1-A upgraded every FILE mtime comparison in this file to nanosecond precision, but the one DIRECTORY comparison left over (projects/'s own mtime, _claude_usage_stale's signal 1) still used file_mtime's whole-second precision. A project directory created in the SAME wall-clock second as the last publish was invisible to that signal in either direction. Both the publish and the check side now read it via files_mtime_size, the same nanosecond stat this file's other two signals already use. P3-4: board_gc_generations gained a directory-name tie-break for its sort (several publishes in the same wall-clock second get the same whole-second mtime), but clean.sh's _clean_board_gc runs the equivalent sweep over the same directories with the old two-key sort. Align it so the two GC paths rank the same set of generations the same way and never disagree on which one keep-N protects.
d1f5e35 to
2a24e8c
Compare
|
Pushed fix round 3, addressing the round-3 review's 2 P1s and 2 P2s (rebase + merge conflicts, claude fuel visibility for a new file in an existing project dir, codex's clock-independent scan root, antigravity's bulk cwd index) plus the P3-1/P3-4 cleanups. Branch is now rebased onto
Details and receipts in the updated PR description. |
Round-4 adversarial review, 2 P1s + 2 P2s + P3-4: - P1-1: an idle claude account (no transcript touched in the last 300 minutes) paid a full rebuild on EVERY frame, forever. Round-3's fix for "the -mmin -300 window is empty at publish" made an empty signal-3 set rebuild instead of trusting it fresh, but rebuilding never changes whether the window is still empty five hours later, so it never stopped. Signal 3 now records the newest K files of EACH project directory instead of the -mmin -300 hit set (never empty unless the account has no claude session at all), and the empty-set exit goes back to fresh. - P1-2: a codex tank with no sessions/ directory yet at its first render (right after `clikae init codex`, before it has ever run) froze its snapshot permanently — no limit it hit afterward ever reached the board, and `clikae clean` could not recover it. board_state_refresh silently dropped a scan-root level that did not exist yet at publish time. Every level _board_scan_root names is now recorded, existing or not: a missing one gets a MISSING sentinel, and board_stale reading it checks existence (has it appeared since?) instead of a mtime that was never there. - P2-1: board_stale used to pay up to five separate stat forks per claude tank (scanroot, claude-usage's root/projdirs/files, recent files) on every single warm read. It now gathers every path all of its signals need first (plain reads, no fork) and pays ONE batched stat call per tank for the union. - P3-4: files_mtime_size's positional zip misattributes a row the moment a path vanishes between building the argument list and the stat call landing — GNU/BSD stat both print one fewer line for a missing arg, silently shifting every zip index after it. Added _board_mtime_size_map, keyed off the path stat itself echoes back instead of argument position, and routed every zip in this file through it (scanroot-mtime, claude-usage's projdirs/files, recent-files, the publish-time file-mtime map).
A new rollout landing in an existing day directory that is not the lexically-newest one was permanently invisible — the same shape as round-3's P2-1, one level down (needs a clock set back, a cross-timezone local-time rewrite, or a synced/restored CODEX_HOME, not the default path, but the consequence is just as permanent once hit). _codex_newest_chain now records every DD directory under the newest MM (bounded: at most 31 per month) instead of only the newest one, so a write to ANY of them bumps a directory board_stale already watches.
board_generation's per-process memo does not survive a `$( )` command substitution — it can only be read by a forked subshell, never written back to. All four render sections (_home_items' live/tanks/recent, plus _home_refresh's own dry-set and board_total) each fork at least one of their own, so a naive per-section call paid board_stale's freshness check, and on a genuine miss a full per-tank rebuild, once PER SECTION PER TANK, every single render (measured: 3 claude tanks, idle fuel window, 24 find calls a frame — 3 tanks x 2 finds x 4 sections). _home_refresh now primes board_generation for every tank once, before any of those subshells exist, so each of them inherits an already-warm cache instead of paying for its own. This also makes board_total ride the same cache as the other three sections (P2-1), instead of walking every tank's generation a fourth time.
|
Round-4 fix pushed ( |
…ounded staleness signal Round 1-4 each shipped a bounded, per-signal freshness approximation (a scan-root directory mtime, an account-level top-K of recorded files, a codex day/month/year chain) meant to avoid re-listing a tank's whole transcript tree on every render. Every round found one more write shape the approximation could not see: a codex limit landing via an append to the current session's rollout was invisible to a board opened from any other cwd (P1); claude's per-project top-K missed an append to the 11th-newest session in its project, and any append to an agent-*.jsonl subagent transcript, both reading as a false green fuel dot until the account's next unrelated write rescued them (P2-2). Design decision: stop approximating. board_stale now re-lists every transcript file under the tank (the exact find board_state_refresh already runs) and re-stats every one of them in a single batched call, reducing the whole set to one opaque fingerprint (count + a CRC over each file's own path/mtime_ns/size, sorted). Equality between that, recomputed fresh on every read, and what board_state_refresh recorded at publish is now the entire staleness signal, for every engine. This is real O(files) work again, but find+stat only, never a parsed line - measured at a few ms per fork for 500 files on this host, well under the parsing costs #62 was written to kill. Deleted outright rather than left beside the new signal: the per-project top-K bookkeeping, _claude_usage_stale and its three sub-signals, every per-engine scan-root mtime/chain (_board_scan_root, _codex_newest_chain), the scanroot-mtime/claude-usage-* on-disk records, and the MISSING-sentinel handling that existed only to keep that catalogue alive across a not-yet-existing directory. board_recent's on-disk row also shrank from 4 fields to 2 - the two staleness-only fields had no other reader once the per-file comparison they backed was gone.
|
P2-2 — GC unlinked an ancestor a live generation still resolved through — fixed in |
|
P3-1 — the |
|
P3-2 — |
|
P3-3 — the shellcheck gate could not see |
|
P3-4 — the round-5 receipt could no longer reach its own specimen — fixed in |
#86 landed on main after round 8 and the PR went CONFLICTING, which is why GitHub stopped running its `pull_request` workflows against this branch at all — no checks, on any platform, rather than red ones. Three files conflicted: * lib/commands/burn.sh — took main's. It supersedes this branch's round-2 comment at the same line and adds the `|| true` its own round-3 review asked for. * tests/bats/burn.bats — took main's. #81's round-1 review found the SAME defect this round's shellcheck work found (a `!`-negated command never fails a bats test, SC2314) and fixed both sites with `run` + `[ "$status" -ne 0 ]`, which is this repo's idiom; the `|| false` spelling from d396968 is dropped in its favour. * CHANGELOG.md — both sides kept, and the round-8 bullet claiming the rate-limit scan is "bounded to the newest CLIKAE_HOME_RECENT_MAX files per project directory" corrected: that bound is exactly what round 9 removed. Round 9's three user-visible fixes are added to the Unreleased section.
…annot merge sibling directories Round 9b. PR #78's macOS job (run 34804107952, `bats (macos-latest)`, test 720) went red on round 9's own receipt: the 專案一 scope's Resume list held s0-0, s0-1, s1-0, s1-1 — its neighbour 專案二's two sessions among them. Linux was green. It was not the entry name: Apple's awk computes round 9's escape byte-exactly (the golden-name asserts added below check that on the runner itself). The merge happened one step later, in the awk that groups the cold build's `recent/` rows by scope: if (sc != cur) { ... start a new entry ... } POSIX awk compares strings in the collation order of the caller's locale, and macOS's awk does exactly that — apple-oss-distributions/awk `src/run.c` relop() is `strcoll(getsval(x), getsval(y))`, for `!=` and `==` as well as `<`. That awk ran without `LC_ALL=C`, the runner's collation is UTF-8, and UTF-8 collation on macOS has no weights for most CJK ideographs, so `專案一 != 專案二` was false: the scope never "changed", 專案二's rows went into 專案一's entry, and 專案二 had no entry at all. No Linux awk can show this, and no glibc locale can either: glibc's strcoll never returns 0 for two distinct strings (measured on C.utf8 and en_US.utf8 for the CJK pair, NFC/NFD café, soft hyphen, ZWSP, U+2060), and gawk/mawk/onetrue-awk use a byte compare for `!=` whatever the locale. So it was reproduced with the real thing instead: Apple's awk (awk-40, "version 20200816") built from source in a Debian container, run under a compiled locale whose collation gives 二 the weight of 一. The unfixed branch then fails test 720 with the IDENTICAL four rows macOS printed; the same build under C.UTF-8 is green, so the collation is the one variable. Fix, twice over: * both awks that ask "is this the same scope / sid?" — the `recent/` grouping and `_board_purge_recent_row`'s filter — run under `LC_ALL=C`, where collation is byte order by definition; * and they no longer ask with `==`/`!=` at all: `bsame(a, b)` (length + index, which never consult LC_COLLATE) is defined once next to `ekey` in `_BOARD_EKEY_AWK` and used by both. The test grows the shapes the brief asked for and one it did not: café in NFC AND NFD as sibling scopes (two directories on Linux, one on APFS, two `$PWD` byte strings either way — nothing may normalize either side); golden escapes for 專案一 and both cafés, asserted on whatever awk and locale the suite runs under; every scope's `recent/` entry on disk; and a PURGE pass (remove one NFD session: only that scope loses a row), which is the second awk this changes. Receipts (in the PR body): Apple-awk + hostile collation, test 720 on fb91dcd RED with macOS's four rows, on this commit GREEN; control arm green on both.
# Conflicts: # lib/adapters/antigravity.sh # lib/commands/clean.sh # tests/bats/adapters/antigravity.bats
…e-second coin toss Round 9b, found while re-running the r8 review's P2-2 receipt on HEAD as the brief asked (r8/p3b-hold.sh, a reader holding a depth-7 generation while publishing continues). Round 9 protected the chain of every generation in "the newest <keep>" — and "newest" was whole-second mtime with the directory name as the tie-break (round-2 P3-1). The name is mktemp's random suffix. Rebuilds inside one wall-clock second are the normal case (every tight refresh loop, every bats run), so which generations counted as the newest five was a coin toss, and the generation a reader still HOLDS could fall out of the roots and be unlinked on the very next publish — chain protection and all. Measured, same probe, four runs each (no code change between the two trees on this path; the merge did not cause it): d396968 (round 9 tip) first publish: held gone in 2 of 4 runs (chain-alive 6/8 and 7/8, 50/50 unresolvable) 3c1b4f2 (after merge) same probe: held survived 4/4 on the first publish, died early in 2 of 4 by publish 7 The round-9 bats receipt for P2-2 was therefore passing on the toss. Fix: every generation records `seq` at publish — the previous `current`'s seq + 1 — and `_board_gc_candidates` (which `clikae clean`'s sweep also calls) ranks by seq, then mtime, then name. Order no longer depends on the clock; mtime and name only break a tie between two processes that published from the same `current`, or rank a generation written before `seq` existed (seq 0, swept first). Materialising copies only sids/ and recent/, so a fresh chain never inherits a stale seq. The receipt stops relying on luck: before the materialising publish it gives the held (newest) generation the OLDEST mtime of all, so the clock disagrees with publish order on every run. RED on 3c1b4f2 (the held generation itself is unlinked), GREEN here.
… and five negations now assert Round 9 (d396968) taught scripts/test.sh and ci.yml to shellcheck `*.bats`. Main's newer test files (#83, #92, #98) were written before that gate existed, so merging main turned it red on 12 findings. Two of them are cosmetic; one is a test that did not test: * clean.bats (#83): five `! grep -qF … "$f"` lines in the middle of a test (SC2314). In bats a leading `!` does not fail the test — `set -e` ignores a negated command — so "the dead sid is gone", "the oldest three are dropped" were never asserted. Now `run ! grep …`, with `bats_require_minimum_version 1.5.0` as cockpit.bats already declares it. All 11 sidecar/burn tests in the file still pass, now with the assertions live. * wait.bats: `_mkstatus … done true` passes the word `done` as an argument (SC1010); quoted. * cockpit.bats: an unused loop variable (SC2034). No production code changes.
…ed stays on Resume
Round 9b, found by re-running the targeted suites on HEAD: the round-5
P2-1 receipt ("_home_refresh in one long-lived process does not reuse a
stale generation") went red once. Repeated under the suite lock it is a
FLAKE that predates this round — 2/12 red on 3c1b4f2, 1/12 on the
pushed tip fb91dcd, 1/6 on 6a4aa49 — and a real one.
Captured on a red run: the refresh DID rebuild (the new generation's
fingerprint equals the live tree's), and `recent/` still listed the
ten fixture sessions without `session-new`. All eleven rows carried the
same whole-second mtime. `_board_merge_recent_row` appended the fresh
row LAST and ran `sort -t␟ -k1,1rn | head -n 10`; `-k1,1rn`'s `r` does
not reverse sort's last-resort whole-line compare, so among equal
seconds rows ranked by the sid's spelling — `session-new` after
`session-9` — and the fresh row was the one `head` cut. The test only
passed when the new file happened to land in a later second.
Fix: the fresh row goes FIRST and the sort is stable (`sort -s`, GNU
and BSD): among equal seconds, what just changed is the newest.
The receipt now gives the new session the SAME mtime as the fixture
(`touch -r`) instead of hoping the clock ticks, so it tests the tie on
every run: RED on 6a4aa49's lib, GREEN here (8/8 repeated).
Also in this commit, a bash 3.2 break in the previous one (6a4aa49),
caught by running `_board_gc_candidates` under docker `bash:3.2`
before pushing: its new `case "$gseq" in ''|*[!0-9]*)` sat inside
`sorted="$( … )"`, and bash 3.2 closes the command substitution at the
pattern's `)` — "syntax error near unexpected token `newline'", an
EMPTY candidate list, and on macOS's /bin/bash GC would have swept
nothing, ever. The loop moves to a helper, `_board_gc_rows`, called
from the substitution. Same run under bash:3.2 afterwards: GC keeps
seq 7 and 6 of seven generations whose mtimes run the other way.
…a tail banner Round-9 review P2-1. `tail`'s `==> name <==` framing is in-band: it travels in the same stream as the file CONTENT that `_limit_batched_readings` folds. The batched window scan matched those separators by PATTERN, so one physical line shaped like one — a torn or partial write, anything third-party writing into the tank tree — ended that file's section early. The reviewer measured both consequences on the real front door: the limit sitting after the line was attributed to a path that was never in the scan list, the transcript's own reading came back EMPTY, and a dry tank drew `●` where the control drew `○ … resets 11pm`. Since claude's `_limit_tank_dry_raw` deliberately never falls back to dry_store, `burn` would dispatch into it — round-8 P1-1's exact failure walking back in through a second door. It also broke this function's own promise, asserted in its header and in bats, that it is a SPEED path and never a narrower answer than `_limit_claude_reading`. A banner is reconciled against the scan list now instead of pattern matched, and a batch whose framing did not come out as predicted is answered per file: - The list is read first (`NR == FNR`), so the scan knows which paths are expected and in which ORDER they were handed to `tail`. A banner is accepted only when it names exactly the next unconsumed path — where this batch's own framing can put one. Everything else is content. The comparison is `bsame` (byte equality), never awk's `==`: on Apple's awk `==` is answered by `strcoll()` and macOS' UTF-8 collation gives most CJK no weight, the same trap 02d8f89 had to fix in board_state.sh, and paths are where it would bite. - Refusing the wrong line still leaves the batch mis-framed from there on, so the batch is TRUSTED only when three signals agree: awk saw no banner-shaped line it did not accept (`/dev/null`'s own banners excepted), the pipeline exited 0, and one row came back per listed path. A fabricated banner naming the very next path is invisible to a row count alone — it consumes that file's real banner later and the totals still balance — which is why the first signal exists. Otherwise the per-file parser answers the whole list. The same check covers a listed path that vanished between the scan and the `tail`: `tail` then prints no banner for it at all and exits 1, which `xargs` turns into 123 — under bin/clikae's `set -eo pipefail` that used to abort the whole refresh, and now takes the per-file path instead. Receipts (fixture: a transcript carrying `==> /etc/passwd <==`, one carrying an earlier sibling's path, one carrying the NEXT sibling's path, plus a listed path that does not exist): before: batched != per-file; a row for /etc/passwd that was never scanned; front door drew `●` on the dry tank, claude-usage = [||] after: batched == per-file on all four; no row outside the scan list; front door drew `○ … resets 11pm`, and the published manifest is byte-for-byte the control's tests/bats/home-bounded.bats pins all four shapes; it fails on the parent commit and passes here.
…e class bash -n cannot see Round-9 review P3-2. `2f51d15` fixed a `case` pattern inside `$( … )` in `_board_gc_candidates`, and nothing in this repo could have caught it: bash 3.2 — what macOS ships and what `bats (macos-latest)` really runs clikae under — does not parse a command substitution's body when it parses the file. Its scanner stops at the first unbalanced `)`, which is exactly what a `case` pattern has, and defers the rest to expansion time. So `bash -n`, `shellcheck -S warning`, the CI syntax gate and every source-scanning guard in compat.bats were all silent, while on 3.2 the substitution failed AT RUNTIME on stderr, yielded the empty string, and the function returned 0 — the board GC swept nothing on macOS and said so to no one. A guard for this class has to EXECUTE, not read. The new test runs `_board_gc_rows` and `_board_gc_candidates` against a real generation chain under `docker run --rm bash:3.2`: HEAD ROWS=5 CAND=1 ERR=(empty) 6a4aa49 ROWS=0 CAND=0 ERR=command substitution: line 730: syntax error near unexpected token `newline' It `skip`s with the reason when docker or the image is absent rather than passing (CI's macOS runners have no docker, and it never pulls), and it carries a control: a known-bad `$( … )` in its own file that must fail inside that container, so green means "fixed" and not "quietly ran on bash 5". tests/README.md gains the section naming the class, the two rules that follow from it (hoist the `case` into a helper; a guard for it must execute), and the trap that cost the review an experiment — `bash:3.2` is Alpine, so busybox's `stat -f '%m'` answers with FILESYSTEM information and `file_mtime` returns garbage there. That is the image, not the code; the probe stubs it out.
…ld never fire
Round-9 review P3-3. `${#SRC[@]:-0}` is not a legal expansion — `${#name[@]}`
is already a count, and `:-` cannot be appended to it. Both call sites printed
`bad substitution` on every run and the tests they guard evaluated to failure,
which is how the gate that exists to refuse a green light meaning "I did not
look" gave exactly that:
before, run in a tree with docs/ but no lib/ or bin/:
doc-names-exist.sh: line 57: ${#SRC[@]:-0}: bad substitution
doc-names-exist.sh: line 61: ${#SRC[@]:-0}: bad substitution
docs name nothing the code lacks (functions, call sites, tmux options)
rc=0 <- zero files checked, gate green
after, same tree:
no source/docs found
rc=1
The line-57 copy also meant the fallback it guards (a checkout sitting inside
some other repo, where `git ls-files` returns nothing) could never run, so that
tree would have taken the same silent pass.
This file is not this PR's — `e226308` introduced it on `main`, and it was
byte-for-byte identical to `origin/main` until this commit. It is fixed here
because the branch's own review ran the gate and the noise was in the receipt.
main moved twice while round 10 was in flight: b030edf (#93 - agy `resume --conversation` plus TANK-scoped Resume rows, issue #34) and 6190f86 (#87 - burn reports the work a run left behind). Round 10 measured the collision and deliberately aborted rather than push a silent regression (REPORT-board62-fix10.md section 4); this merge lands it. Three textual conflicts, all resolved keeping both sides' intent: * tests/bats/clean.bats - both sides fixed the same five SC2314 lines, differently. Kept this branch's `run ! grep ...` (3bd9189, mutation- tested in round 9) over main's `! grep ... || false`. Same assertion. * tests/bats/compat.bats - both sides appended a new guard at the same place. Both kept: this branch's "board GC still RUNS under a real bash 3.2" and main's three GNU-ism scans (touch -d, date -d, stat -c). * lib/adapters/antigravity.sh - #93 deleted `adapter_recent_sids`' cwd filter (workspace is a constant on a real install, so the filter hid every row); this branch added the board's bounded fast path on top of it. Kept main's semantics AND the fast path: `$n` naming, no `$want` filter, tank-scoped scan, with `board_recent` in front of it. This commit is the MECHANICAL resolution only. main's six now-red tests are a semantic mismatch between this branch's index and #93, not a conflict; the two commits that follow adopt those semantics.
#93 (b030edf) deleted the cwd filter from agy's `adapter_recent_sids`: a real agy install writes ONE `workspace` value for every session (the CLI's own root, not the user's cwd), so filtering by `$PWD` answered "no sessions" from every project directory and the board's Resume block for agy was permanently empty. This branch's bounded index reproduced the same bug one layer down, and would have hidden #93's fix completely: `board_recent` answers from `recent/<key(scope)>` and RETURNS FIRST, so the adapter's now-correct disk scan never runs on a warm board. The index keyed agy by cwd like every other engine, so it had no rows to answer with. That is what made main's "agy board Resume rows show sessions regardless of recorded workspace, newest first (#34)" red against this branch. The index now uses one scope per tank for agy (`_BOARD_TANK_SCOPE`, `#tank` — not a path, so it cannot collide with a real cwd, and `_board_entry_key` percent-encodes it identically on the write and the read side). Both sides move together: `_board_scope_raw` (read) and `_board_engine_sidscope` (write). Two things fall out, neither of them the reason: * `_board_engine_sidscope` no longer opens a file for agy at all — the `adapter_session_cwd` fallback fork per session is gone. * `board_state_refresh` no longer calls `_agy_ws_load`, and neither does `adapter_recent_sids`. That bulk workspace index existed to serve the cwd keying and the cwd filter; with both gone it was one awk pass over the tank's metadata per refresh for a value nothing reads. The helpers stay where they are with their receipts in tests/bats/adapters/antigravity.bats — `adapter_session_cwd_index` is this adapter's documented bulk hook and burn.sh/resume.sh still use the single-session form. Receipt (tests/bats/home.bats, tests/bats/home-bounded.bats, tests/bats/board-width.bats, one bats run each side): before 108 ok / 6 not ok (test 24 among them) after 104 ok / 10 not ok (test 24 GREEN; the agy #93 P2-1 rows became reachable for the first time and go red with the claude/codex ones — the next commit is what those need)
…2-1) THE CUT AND THE BURN-SIDECAR EXCLUSION ARE NOT COMMUTATIVE. `_home_recent_rows` drops burn one-shots from the Resume rows AFTER the adapter has answered, so an answer already cut to N gives the filter N rows to throw away and nothing to promote. #93 fixed that by widening what it ASKS each adapter for, per tank, by that tank's own hidden count (`N + _burn_tank_hidden`, ceiling `CLIKAE_HOME_RECENT_SCAN_MAX`, which defaults to `CLIKAE_BURN_SIDECAR_CAP` = 2000), with a T_RESUME_TRUNCATED line when the ceiling really bites. On a warm board this index is what answers that ask, and a `recent/` entry cut to 10 at BUILD time cannot hand back 205 rows however wide the ask is — `board_recent` RETURNS FIRST, so the adapter's own scan (which main fixed) never runs. That is why main's five remaining tests were red here: 195 burns + 50 humans rendered zero human rows, and a 250-burn tank lost its Resume block entirely, silently. So `board_state_refresh` computes the same widened cap, with the same granularity and the same ceiling, and builds the entry at it. Per TANK, never per store (#93 round-2's own finding): a row this tank returns can only be dropped by a sid recorded for THIS tank, so another engine's burn-heavy tank must not push this one's cap into the ceiling. A tank with no sidecar keeps N exactly, so the common entry is byte-for-byte what rounds 5-10 wrote. `_burn_tank_hidden` lives in lib/commands/home.sh, so the call is behind `declare -F`, the same rule every adapter hook in board_state.sh already follows: unsourced home.sh => no widening => the pre-#93 cap, which is the answer this file gave for ten rounds. `CLIKAE_RESUME_ALL=1` skips the widening on both sides, so the two stay in step there too. The READ side is deliberately unchanged: the entry's own cap bounds the answer (an entry built at cap 2 answers an ask of 10 with 2 rows). An earlier draft of this commit made board_recent fall through to the disk scan whenever the ask exceeded the cap; that is a real guard, but it contradicts two of this branch's own receipts ("snapshot selects newest main sessions", "does not reuse a stale generation on its next refresh"), which define the index cap AS the bound. Keeping the two caps in step at BUILD time is where this belongs. The residue is named in the PR body. Receipt (tests/bats/home.bats, home-bounded.bats, board-width.bats): merge head 108 ok / 6 not ok after the tank-scope commit 104 ok / 10 not ok after this commit 114 ok / 0 not ok The ten now green: #24 (#34 tank-scoped rows), #29/#31/#32 (12 burns over 3 humans — agy/claude/codex), #72/#73/#74 (195 burns + 50 humans — agy/claude/codex), #75 (250 burns + 3 humans), #77/#78 (the ceiling truncation note), #79 (no note on an ordinary board).
main moved again while this round was in flight: eb58aab (#89 - per-tank fuel from the vendor usage endpoint, read by the board dots and by burn). Three textual conflicts, all mechanical: * bin/clikae - both sides added a `source` line to the same block. Both kept: this branch's reading_cache.sh + board_state.sh, then main's usage.sh, in main's own position relative to history.sh. * lib/commands/home.sh - a COMMENT block both sides rewrote (the header that used to promise "fork-free"). Took main's: it is the superset, it documents main's own new per-tank `jq` cost, and it keeps the codex paragraph this branch's version was narrowing to. * tests/bats/compat.bats - both sides appended a guard at the same place. Both kept: this branch's "no declare/local/typeset -g/-A/-n" pair and main's real-bash-3.2 `bash -n` parse gate with its negative control. Nothing in this branch's own design needed to move for #89: the board index and the usage cache do not share a code path. Receipts on the merged tree (targeted, under the suite lock): compat/home/home-bounded/board-width 130 ok / 0 not ok usage/burn/limit-codex-status/limit/limit-reset 336 ok / 0 not ok adapters+antigravity/resume/resume-hide-burn/clean 191 ok / 0 not ok shellcheck -S warning bin/clikae install.sh scripts/test.sh rc=0 scripts/doc-names-exist.sh rc=0 git diff --check rc=0
main moved again: 459c981 (#102 - the tmux status row; it also moved `_human_age` out of home.sh into lib/core/duration.sh). One textual conflict: * scripts/doc-names-exist.sh - BOTH sides made the same fix, this branch's 2b53f98 and main's #102 round-2 P3-6: `${#SRC[@]:-0}` is a bad substitution on bash 4+/5, so the "no source/docs found" insurance could never fire. Took main's version, which is the same one-character change plus a comment naming the round it came from. Everything else auto-merged. Gate on the merged tree: `scripts/doc-names-exist.sh` rc=0, `git diff --check` rc=0. main is RED on this tree, at 459c981, in a way this merge inherits rather than causes - fixed in the commit that follows, not here.
…b it needs main is red at 459c981, and this is not a merge artefact: #89 (eb58aab, the vendor usage block in `_home_fuel_dotv_compute`) and #102 (459c981, which moved `_human_age` into lib/core/duration.sh and guarded home.sh's two call sites) landed in the same window and crossed. Reproduced on a clean `origin/main` worktree, nothing of this branch in it: bats -f "_human_age call in home.sh" tests/bats/home.bats not ok 1 unguarded: 1265: [ "$age" -lt "$ttl" ] || _FNOTE="$_FNOTE · $(_human_age …)" bats -f "stale-but-recent vendor reading" tests/bats/usage.bats _home_fuel_dotv_compute '' claude work "$now" failed with status 127 Two halves, both of them main's, fixed here rather than carried: 1. lib/commands/home.sh - #89's call site now carries the same `declare -F _human_age` guard as the other two, on the same LINE, because tests/bats/home.bats asserts the guard per code line (reaching this one needs a real usage cache, so nothing else catches it). Same `&&`-list shape as the existing sites: errexit exempts every command in an `&&` list but the last, so a missing function is a no-op. 2. tests/bats/usage.bats - the three tests that source home.sh STANDALONE now also source lib/core/duration.sh, which `bin/clikae` loads globally and they were relying on home.sh to define until #102 moved it. Without it the age can never render and the assertion cannot pass at all. The assertions themselves are untouched, byte for byte. tests/bats/home.bats' call-site count goes 2 -> 3 for the same reason: #89 added a third site. The guard assertion - the substance - is unchanged; the count is there so a NEW site cannot appear unguarded, and it has to name the number that exists. Receipts: usage.bats 40 ok / 0 not ok; home.bats + home-bounded.bats + board-width.bats + compat.bats + doc-contract.bats + tmux-label.bats 148 ok / 0 not ok.
main moved a third time inside this round: b6e8dc1 (#91 - burn reroute never targets a lock file, sidecar or dotdir; it also routes the board's own tank walk through the `tanks_for_engine` marker and adds a per-process tank cache). Two conflicts: * lib/commands/home.sh, `_home_refresh` - both sides rewrote the whole function. #62 replaced main's parallel `mktemp` + background `_home_dry_set`/`_home_total_sessions_scan` machinery with the board index (`board_generation` priming + `board_total`); #91 added a `profiles_cache_reset`/`profiles_cache_warm` prologue on top of the OLD body. Kept THIS branch's body - the replacement is what #62 is - and adopted #91's prologue into it, placed FIRST: the board priming walks the same tank enumeration, so warming after it would pay the uncached store walk the warm exists to avoid. * CHANGELOG.md - both sides added entries at the top of Unreleased/Fixed. Both kept, this branch's two first. `_home_recent_rows` auto-merged and needed no help: main had already reconciled #61's `tanks_for_engine` enumeration with #93's per-tank `$_ask` inside that walk (its own "#61 round-6 P2-1 (merge with #93)" comment), and the bounded reader is fed by exactly that marker-filtered list - `tanks_for_engine` is `list_all_profiles | awk`, which is also what this branch's board priming loop iterates, so the two cannot drift onto different tank sets. Receipts on the merged tree (targeted, under the suite lock): home/home-bounded/board-width/usage 157 ok / 0 not ok limit*/adapters+antigravity/resume*/clean/compat 273 ok / 0 not ok burn/init/cockpit/doc-contract/tmux-label/antigravity 337 ok / 0 not ok shellcheck -S warning bin/clikae install.sh scripts/test.sh rc=0 scripts/doc-names-exist.sh rc=0 git diff --check rc=0
…PR's own gate This branch added a CI step in round 8 (P3-3): `find tests -name '*.bats' -print0 | xargs -0 shellcheck -S warning`. The `ludeeus/action-shellcheck` step above it does not look at `.bats` at all, so before that step a finding in a test file was invisible on both sides. main has none of that step, which is why main's `shellcheck` job is green while this branch's went red on 3d022aa - the gate is this PR's, the findings are main's: tests/bats/limit-codex-status.bats SC2034 ts (x3 call sites) tests/bats/usage.bats SC2154 stderr tests/bats/tmux-status.bats SC2034 mt, SC1010 done, SC2034 CLIKAE_BURN_LOG_RETENTION_DAYS, SC2034 t27 Every fix keeps the behaviour and every assertion byte for byte: * `ts` -> `_`. Its whole job (the file says so) is to absorb the 7th field so `read`'s "extra fields glom onto the last variable" rule does not append it onto `$sr`. `_` absorbs it identically and is the one name shellcheck exempts. Only the three sites that never READ it changed. * `$stderr` is set by bats' own `run --separate-stderr`, which shellcheck cannot see: a `# shellcheck disable=SC2154` with the reason, the same way burn.bats already documents cmd_burn's dynamically scoped locals. * `local v mt age suffix` -> `local v age suffix`: `mt` is declared by the `local "$v"=""` inside the loop, like `d` and `now`, which were never named out there either. * `_burn_status burn-2 done …` -> `"done"`: bare, it reads as the keyword. * `CLIKAE_BURN_LOG_RETENTION_DAYS=30` -> `export`. `tmux_status_render` runs in this shell so either form reaches it; only the export says the value is read somewhere. * `t27` was built and never used: dropped, `t24`/`t42` are the two widths the rungs actually exercise. Receipts: `find tests -name '*.bats' -print0 | xargs -0 shellcheck -S warning` rc=0 (the CI step, verbatim); the same over all 199 *.sh/*.bats under lib/ tests/ scripts/ rc=0; `shellcheck -S warning bin/clikae install.sh scripts/test.sh` rc=0. tmux-status.bats + limit-codex-status.bats + usage.bats: 139 ok / 0 not ok.
…tion lib (#115) main went red on both bats platforms after #89 (a new _human_age call site) and #102 (which moved _human_age into lib/core/duration.sh and guarded only the call sites it knew) landed in the same window. Cherry-pick of the repair the #78 integration lane proved on its merged head: declare -F guard at the call site; usage.bats sources duration.sh.
…0 P2-1) The Resume index's per-tank cap is widened by that tank's hidden burn count (#93's fix, adopted into the index in round 11), but `board_stale` fingerprinted TRANSCRIPTS only — and the sidecar is an input to the cap, not to the transcripts. burn.sh records a burn's sid AFTER the engine process exits (burn.sh:1106, :4288), so "transcript appears, board renders, sid lands" is burn's own ordering, not a contrived one: the entry got built at the old, narrower cap and nothing afterwards ever disagreed with the fingerprint again. Round 10 measured it, on this branch, through the front door: 10 human sessions + 1 burn, in burn's real order -> 9 human rows, on that frame and on all three later ones. It does NOT self-heal; only deleting state/board brought the tenth back. main renders 10. An entry built at cap 10 out of 50 burn transcripts, then 50 sids appended with no transcript touched -> HUMAN rows 0, BURN rows 0, no truncation line, no Resume block at all. main renders 10. The PR body's claim that this "self-heals on the next change" was an argument, and it was wrong. Fixed at the root, in two halves, because one is not enough: * `_board_sidecar_rows` stats this tank's sidecar file(s) and `_board_tank_fingerprint` (was `_board_transcript_fingerprint`) hashes them after the transcript rows, so a sidecar write makes THAT TANK, and only that tank, stale. Both engine spellings: burn.sh writes agy's under the literal "agy" while this file calls the engine "antigravity" — the same translation `_burn_tank_hidden` carries. One stat per tank, on a file that exists only where burns have run; tanks without one hash exactly what they hashed before. * A generation now records the cap its `recent/` rows were cut at (`recent-cap`), and an incremental rebuild re-cuts every entry when it moves. Without this the first half is inert: an incremental rebuild rewrites an entry only for a file that CHANGED, and a sidecar write changes no file, so the rebuild carried the narrow entry forward verbatim (verified: with only the fingerprint half in place, both shapes above stay red). The re-cut reads the manifest this refresh just published — every file's sid and scope are already in it — so it opens no transcript and re-parses nothing, and it goes through the same one function the cold build uses (`_board_write_recent_entries`, factored out here) so the two produce byte-identical entries. Ordering inside the rebuild is part of the fix: the sidecar is stat'd BEFORE `_burn_tank_hidden` counts it, so the published fingerprint describes a sidecar state no NEWER than the one the cap was computed from. The other order manufactures the same bug in miniature — a fingerprint vouching for a sidecar the cap never saw, fresh forever. The generation's fingerprint file is `tank-fp`, not `transcripts-fp`: it covers something the old name does not, and the rename makes each side read the other's generation as stale (one incremental rebuild) instead of as a fingerprint vouching for the same thing. `_BOARD_GEN_FORMAT` is deliberately NOT bumped — a format mismatch forces a COLD rebuild, while a missing fingerprint takes the cheap incremental path, and the upgrade needs the cheap one. Tests (red on 03d4884, green here): a sid recorded AFTER the render still leaves ten human rows on the next frame 9 -> 10 sids appended to an entry already built at the old cap never empty the Continue block 0 -> 10 a sidecar change rebuilds that tank ONCE, not on every frame (never stale) -> 1 rebuild, then 0 an agy tank's sidecar is fingerprinted under the 'agy' alias Receipts: home-bounded + board-width 39 ok / 0 not ok; home + resume-hide-burn + adapters/antigravity 140 ok / 0 not ok; shellcheck -S warning rc=0; scripts/doc-names-exist.sh rc=0.
`adapter_session_cwd_index` and the `_agy_ws_*` plain-global cache built on it (`_agy_ws_varname`, `_agy_ws_clear`, `_agy_ws_load`, `_agy_ws_lookup`, plus `_AGY_WS_NS`/`_AGY_WS_KEYS`) existed for exactly ONE reader: the `$want` cwd filter in antigravity's own recent-sid scan. #34 deleted that filter (`workspace` is a constant on a real install, so cwd-scoping hid every row), and fix round 11 dropped board_state.sh's bulk read along with the cwd keying it fed (`_BOARD_TANK_SCOPE`). What was left was a documented adapter hook, a three-function cache and a namespacing scheme with no caller in `lib/` or `bin/` at all — the round-10 review confirmed it mechanically, and the only users left were the two bats tests that exercise the cache itself. Deleted, with those two tests, and every comment that pointed at them rewritten to say what the code does now instead of naming a function the tree no longer has. `adapter_session_cwd` — the single-session form burn.sh and resume.sh actually call — is untouched: same file read, same "first occurrence wins", one session at a time. adapter_loader's `unset` list, which the review flagged for not carrying `adapter_session_cwd_index` (a hook that would therefore survive across a two-adapter load), needs no entry for it now: there is no such hook. The list's remaining gaps are a separate finding, fixed in the next commit. Both gates stay green either way, which is why this was a P3 and not a blocker: `shellcheck -S warning` has no rule for an unused function, and `docs/` never named any of them. The reason to delete is that a documented hook with no caller is a claim the code does not keep. Receipts: adapters/antigravity + home-bounded + resume-hide-burn 93 ok / 0 not ok; shellcheck -S warning (lib + every .bats, CI's own line) rc=0; scripts/doc-names-exist.sh rc=0; no `_agy_ws`/`adapter_session_cwd_index` reference survives anywhere outside two past-tense history notes.
Found while closing round 10's P3-1, which flagged ONE name missing from adapter_loader's `unset` list (`adapter_session_cwd_index`, deleted in the previous commit). Checking the list mechanically instead of by eye — every `^adapter_*()` under lib/adapters/ against the list — found four more: adapter_cwd_from_args codex, claude, antigravity, grok adapter_ephemeral_flags claude adapter_mcp_config_file claude adapter_tank_fingerprint claude, antigravity, codex The list exists because `clikae handoff <a> --to <b>` loads two adapters in one process, and an optional hook `b` does not define must not be inherited from `a`. Leaked, it does not read as a bug: `declare -F` says the hook is there, so a caller gating on it believes the NEW engine supports whatever it gates. That is #60's exact defect (`adapter_meta_permission_modes`, round-2 P1-1 of that PR) and #81's before it (`adapter_burn_flags` / `adapter_audit_flags`). Three drifts, three times caught by a person reading the list, each time fixed only for the hook that person was thinking about. So the fix is the list AND a gate: "every adapter hook is in adapter_loader's unset list" (tests/bats/adapters/extra.bats) compares the two sets and prints what is missing, and a second test proves the same thing in a live shell (load claude, load grok, the three hooks grok does not define must be gone). Negative control run both ways: with `adapter_tank_fingerprint` taken back out of the list, both tests go red and name it; with it in, both are green. Receipts: adapters/extra 20 ok; handoff + adapters/{claude,codex,grok} + ephemeral 97 ok; burn 217 ok; all 0 not ok. shellcheck -S warning rc=0.
Closes #62.
What
The board render no longer scales with transcript count or size:
lib/core/board_state.sh— session-boundary discovery for claude / codex / antigravity / grok; SID→path indexes; bounded recent lists per scope (CLIKAE_HOME_RECENT_MAX, default 10); session counts and usage readings published as immutable generations.lib/core/reading_cache.sh— per-file parser cache keyed by reading kind + path + size + sub-second mtime; caches empty/negative readings; follows log symlinks; publishes through uniquemktempfiles.lib/core/limit.sh/scan.sh: cached per-file limit/success readings and agy email extraction; the board consumes snapshots.lib/commands/home.sh: bounded board mode on; opt-in millisecond timings for live/tanks/recent.Staleness model (as of round 5): one batch stat of every transcript;
parse only what changed. See "Fix round 5" below for why rounds 1-4's
per-signal approximations were replaced outright rather than patched again.
Fix round 1 (this push)
A round-1 adversarial review found 3 P1s and 3 P2s on top of a confirmed performance win. All are addressed:
home.sh's live-row title guess and the all-projects glob fallback used to be disabled outright in board mode. Each adapter'sadapter_recent_sids/adapter_find_session(and codex/grok's twins) now try the snapshot first and only fall through to the original live scan on a genuine miss — bounded by tmux session count, never by transcript count.board_generationnow does one cheap per-tank freshness check at render time (a directory mtime plus a bounded handful of file mtimes, compared for equality against what was recorded at publish — never>against a wall-clock stamp) and rebuilds inline, only for the one tank being read, the instant it disagrees with what was last published.limit.sh's arbitrary snapshot-age gate is removed; freshness is now judged directly instead of guessed at.cmd_run/_agy_switchno longer wrap the engine in a subshell to run a post-launch refresh — that refresh is unnecessary now that render-time self-heal exists. Both call sites are back to a bare tail call /exec.board_state_refreshGCs its own tank's generations on every publish (newest N, no age floor);clikae cleanadditionally sweepsstate/boardfor tanks that haven't relaunched in a while andstate/readingsfor entries whose source file is gone.board_key/sids/recentlookups now verify the raw sid/scope they were keyed by, so a 32-bit cksum collision reads as a miss instead of another session's data; the new stat probes reuse the existing GNU/BSD detection instead of astat -c … || stat -f …fallback;mktempfailures inlimit.shno longer redirect to an empty path.Rebased onto current
main(onlyCHANGELOG.mdconflicted, resolved additively).Measurements (round 1)
Same synthetic store as round 1 (500 agy + 50 claude transcripts, ~56 MB, one cwd), through the real
bin/clikaefront door,CLIKAE_NO_INTERACTIVE=1:main → branch/warm output is byte-identical (
diffrc=0).clikae run claude x's own board-refresh overhead (engine stubbed): 955 ms → 1 ms.clikae agy x -- -p …'s own overhead: 14.6 s → 20 ms.Full
bats tests/batson this branch: rc=0, all tests green.shellcheck -S warningrc=0 on every changed file;git diff --checkclean.Full report:
/home/kitt/lanes/REPORT-board62-fix1.md(not part of this repo).🤖 Generated with Claude Code
Fix round 2 (this push)
A round-2 adversarial review found 3 P1s and 2 P2s on top of round-1's
fixes — 4 of round-1's 6 findings were fully fixed, 3 were half-fixed
(same shape, tighter window). All are addressed:
recent/<key>rows now carry a size column alongside the nanosecond-precision mtime
(
files_mtime_size's%.9Y, the same fix_reading_cache_keyvalready made), and
board_stalecompares both. Previously a writelanding in the same wall-clock second as the last publish was
invisible in either direction — a limit landing read as fresh, a
resolved limit stuck reading stale.
limit_profile_dryreadsclaude-usage, which scansprojects/infull — every project, not just the current one — but
board_stale'ssignals were all PWD-scoped, so a limit landing in a different
project directory never invalidated this one's board.
board_state_refreshnow also records
projects/'s own mtime (a brand new projectdirectory) and the (mtime, size) of every file inside the
-mmin -300window at publish time (an append to a session in a project that
already existed) — both bounded, never a tree walk — and this check
runs before the per-scope early return, so a scope with no recorded
sessions still answers for an account-level limit.
sessions/ itself). A new rollout never touched
sessions/'s ownmtime, so a new codex session never appeared in Resume and its dry
reading never updated. The scan root is now the deepest EXISTING
directory on today's
sessions/YYYY/MM/DDpath — computed from thewall clock, not discovered via
find(this repo's own test suitehard-fails any
findcall on a warm render).Any write to
history.jsonl— one new agy session anywhere —invalidated every OTHER session's cached cwd at once. On a synthetic
500-session tank this turned "one new session" into a 500-entry
cache stampede:
board_state_refreshmeasured ~9.9s with the extrawrite vs ~5.2s unchanged. The cache is now keyed on the session's own
transcript file; the same measurement now shows ~5.2s either way.
removed the boundary-call
board_state_refreshinrun.shandantigravity.shfor the reason recorded inrun.sh's own comment(render-time self-heal already covers it);
burn.sh's two callsites were missed. Removed.
board_gc_generations' sort now breaksties on directory name (several publishes inside one wall-clock
second no longer sort non-deterministically);
load_adapternowruns before
mktemp -d, so a load failure can't leave an orphangeneration directory.
Every fix verified with a deterministic before/after receipt (pre-fix
code reproduces the bug, post-fix code doesn't) — see the round-2 fix
report for the full methodology and numbers.
Re-measured headline (round 2)
Same synthetic store shape as round 1, through the real
bin/clikaefront door,
CLIKAE_NO_INTERACTIVE=1, quiet host:Output
diffrc=0 between main and branch on this store. Fullbats tests/bats: rc=0, 1175/1175.shellcheck -S warningrc=0 onevery changed file;
git diff --checkclean.Full report:
/home/kitt/lanes/REPORT-board62-fix2.md(not part of this repo).Fix round 3 (this push)
A round-3 adversarial review found the branch had not been rebased (merge-base
still at
941bb49, three commits behindmain) plus 2 P1s and 2 P2s on top ofa re-confirmed performance win. All are addressed:
P1-1 (branch was never rebased; the merge conflicts didn't resolve
cleanly). Rebased onto current
main(ab6fb52, bringing in board: a dry verdict expires when its own reset instant has passed (#75) #79/antigravity: session title from conversation_summaries.db, not the opening prompt (#73) #80/burn: guard headless claude runs against sub-agent delegation (--disallowedTools Agent,Task + bg-wait ceiling 0) #82).Two textual conflicts:
lib/adapters/antigravity.sh(#80'sconversation-summary title lookup now stays intact, wrapped by this PR's
per-file cache instead of being replaced by it) and
tests/bats/home.bats(two same-behavior style conflicts, kept
main's idiom).#79'sdry-reset-expiryrewrite also changed_limit_codex_dry's contract (areal recovery after a limit is now
rc=2, distinct fromrc=1"nothingfound"); this PR's own codex-snapshot test predated that and expected
rc=1— updated torc=2, matching#79's contract.Ruling: a bare render MAY write its own derived cache under
$CLIKAE_HOME/state/— never under any tank's profile dir. The read-onlytest ("bare clikae changes nothing on disk") now asserts exactly that
boundary:
profiles/(the tank's source data) is byte-for-byte untouched bya bare render;
state/is excluded from the comparison because it isallowed, not required, to change.
P1-2 (claude's account-level fuel never saw a file that appeared after a
publish, in an already-existing project directory).
_claude_usage_stalehad two signals —
projects/'s own mtime (a brand new project directory)and the recorded (mtime, size) of files that were in the
-mmin -300windowat the last publish — and neither moves when a brand new FILE lands inside
an EXISTING project directory. Now also records each existing project
directory's own mtime (bounded by project count, one more batch onto the
same
files_mtime_sizecall). Separately, an EMPTY tracked-file set atpublish used to read as "nothing to compare, call it fresh" — permanently,
since an append to an old file re-entering the window moves no directory's
mtime either. That exit now rebuilds instead of trusting silence.
P2-1 (codex's freshness signal computed "today" from the OBSERVER's
clock, not from what codex actually wrote).
_codex_today_scan_diruseddateto guess a day directory and walked up to the nearest existingancestor — correct only when the observer's local "today" agrees with
whatever clock wrote the newest rollout's date directory (true by default,
false under an explicit
TZ, travel, or a CI/cron zone mismatch).Replaced with
_codex_newest_chain, which reads the actual newestYYYY/MM/DDchain off disk (bounded glob per level, nofind, no clock).P2-2 (a change on the agy tank still cost 1.8x main on the very first
render after it). Round-2 killed the cache-key stampede, but antigravity's
per-session cwd lookup (
adapter_session_cwd, onereading_cache_runcallper session) still cost a stat + a cksum + a subshell read PER SESSION even
on a warm cache hit — ~5s fixed on a synthetic 500-session tank. Added
adapter_session_cwd_index: oneawkpass overhistory.jsonlbuildingthe whole sid → workspace map at once; the rebuild loop and the discovery
fallback both use it (falling back to the per-file lookup when unavailable),
turning 500 forked lookups into 500 in-memory array reads.
P3-1/P3-4 also tidied: the one remaining whole-second directory-mtime
comparison in
_claude_usage_stale(projects/'s own mtime) is nownanosecond precision, matching P1-A's file-level fix;
clean.sh's_clean_board_gcsort now carries the same directory-name tie-breakboard_gc_generationsalready had, so the two GC sweeps rank the samegenerations the same way.
Every fix verified with a deterministic before/after receipt (pre-fix code
reproduces the bug, post-fix code doesn't) on a throwaway
HOME+CLIKAE_HOMEunder
mktemp -d— the real~/.clikaewas never touched.Re-measured headline (round 3)
Same synthetic store shape as rounds 1-2 (500 agy + 50 claude sessions,
~103 KB each, one cwd, 62 MB / 551 files), through the real
bin/clikaefront door,
CLIKAE_NO_INTERACTIVE=1:burn-and-check workflowactually sees) — an agy tank getting one new session: ~3.7-3.9 s, at or
below
main's own ~3.6-3.9 s baseline on the same store (round-2 had thisat ~6.4 s, 1.8x
main).state/stays bounded relative to thesource store regardless of session count — a few MB of reading-cache
entries plus a handful of board generations per tank, not one entry per
source transcript.
first number (316 ms) predates round-1's OWN P1-2/P1-3 fixes for a stale
Resume list / frozen fuel reading — those fixes added
board_stale'sper-render freshness check (a handful of directory/file mtime comparisons,
still O(1)-ish, never a tree walk) that must run on every read before
trusting a published snapshot. That correctness cost is what moved the warm
floor from ~0.3 s to ~0.6 s, and it has stayed there since (round 2, round
3) — it is the price of the snapshot never going silently stale, not a
regression introduced later.
Full
bats tests/bats: rc=0, 1203/1203.shellcheck -S warningrc=0 on everychanged file;
git diff --checkclean.Full report:
/home/kitt/lanes/REPORT-board62-fix3.md(not part of this repo).Fix round 4 (this push)
A round-4 adversarial review found 2 P1s and 2 P2s on top of a re-confirmed
performance win (round-3's 2 P1s + 2 P2s, plus P3-1/P3-4, all fixed and
re-verified). All are addressed:
minutes paid a full rebuild EVERY frame, forever — and once per render
SECTION, not once per render). Round-3's fix for "the publish-time
-mmin -300window is empty" (P1-2 there) made that empty-set caserebuild instead of trusting it fresh — but rebuilding never changes
whether the window is still empty, so it never stopped rebuilding. This
is not an edge case: it is every account's first
clikaeof the day.Signal 3 (
board_state.sh's_claude_usage_stale) now records thenewest K files of EACH project directory instead of whatever the
-mmin -300window happened to catch — a set that is empty only when theaccount has never written a claude session at all — and the empty-set
exit goes back to "fresh". Separately,
board_generation's per-processmemo does not survive a
$( )subshell, so all four render sections(
_home_items's live/tanks/recent plus_home_refresh's own fuel scan)each forked their own rebuild for the same tank;
_home_refreshnowprimes every tank's generation once, before any of those subshells
exist, so a
$( )fork inherits an already-warm cache instead of payingfor its own.
sessions/directory yet at its firstrender — i.e. right after
clikae init codex, before the tank has everactually run — froze its snapshot permanently; no limit it hit afterward
ever reached the board, and
clikae cleancould not recover it).board_state_refreshsilently dropped a scan-root level that did notexist yet at publish time, so nothing was ever watching for it to
appear. Every level
_board_scan_rootnames is now recorded, existingor not — a missing one gets a
MISSINGsentinel instead of beingdropped, and
board_stalereading that sentinel checks EXISTENCE (hasit appeared since?) instead of comparing a mtime that was never there.
than main, on an otherwise-identical small/idle store — 6 tanks: main
409ms vs branch 1906ms, 4.7x). Same root cause as P1-1's second half
(duplicated per-section board_generation calls) plus each tank's own
freshness check paying up to five separate stat forks (scanroot,
claude-usage's root/projdirs/files, recent files).
board_totalnowrides the same primed cache as the other three sections, and
board_stalegathers every path any of its signals need before stat-inganything, paying ONE batched stat call per tank instead of up to five.
not the lexically-newest one was permanently invisible — same shape as
round-3's P2-1, one level down: needs a clock set back, a cross-timezone
local-time rewrite, or a synced/restored
CODEX_HOME, not the defaultpath).
_codex_newest_chainnow records every DD directory under thenewest MM (bounded: at most 31), not only the newest one — a write to
ANY of them bumps a directory the check already watches.
files_mtime_size's positional zip misattributes a row themoment a path vanishes between building the argument list and the stat
call actually running — GNU/BSD stat both print one fewer line for a
missing arg, silently shifting every zip index after it). Added
_board_mtime_size_map(board_state.sh, local to this file — theshared
files_mtime_sizeprimitive's " " contract has othercallers that already parse it positionally): keys off the path
statitself echoes back, so a vanished path is a missing map entry, never a
value recorded against the wrong path. Every zip in
board_state.sh(scanroot-mtime, claude-usage's projdirs/files, recent-files, the
publish-time
_fmap) now goes through it.Every fix verified with a deterministic before/after receipt (pre-fix
2a24e8creproduces the bug, post-fix doesn't) on a throwawayHOME+CLIKAE_HOMEundermktemp -d— the real~/.clikaewas nevertouched. P3-4's receipt deletes a file between building the stat argument
list and the stat call landing (a stat-wrapper race) and shows the old
positional zip misattributing the next file's data, then shows the fixed
path-keyed lookup reporting a missing key instead.
Re-measured headline (round 4)
Through the real
bin/clikaefront door,CLIKAE_NO_INTERACTIVE=1, warmbest-of-3, one throwaway store per tree:
Idle claude account (the shape P1-1 fixed — no transcript touched in 300
min), 3 claude tanks × 20 sessions each:
2a24e8c)24 finds/frame → 0 after the first render, forever — not "less often", gone.
Claude-only, single tank, 400×100KB transcripts (round-4 review's
scenario C):
Multi-tank, small store, fully warm (round-4 review's scenario D — 1/3/6
claude tanks × 10 sessions):
The per-tank marginal cost this fix targets (
board_generation/board_staleitself, isolated from the rest of the render) dropped from ~260ms/tank to
~14ms/tank (measured directly, bypassing home.sh's other per-tank work).
Honest disclosure: the WHOLE render's multi-tank number above is still
measurably slower than main (not the "must match main" bar this round
aimed for) — the residual ~40ms/tank lives in per-tank work OUTSIDE
board_state.sh(label lookups, dry-set scans, per-call scope/keyresolution) that this round's review did not flag and this fix did not
touch. Filed as follow-up, not silently dropped.
State size (steady-state
state/after a publish, independent ofsession count): measured 1.7–3.3 MB across this round's fixtures
(single claude tank, 400×100KB transcripts) — consistent with round-3's
"a few MB regardless of source size" claim; round-1/round-3's own
5.5MB/2.6MB/12MB figures were from different fixture shapes and sizes, not
a discrepancy in the mechanism.
Ruling, restated: a bare
clikaerender may write its own derivedcache under
$CLIKAE_HOME/state/— never under any tank'sprofiles/dir. That boundary is enforced by
tests/bats/home.bats's "bare clikaechanges nothing on disk" test (compares
profiles/byte-for-byte,excludes
state/), not just documented in a comment.Full
bats tests/bats: rc=0, 1203/1203 (three ≤600s segments, 514+427+262).shellcheck -S warningrc=0 on every changed file, with and without-x.git diff --check origin/main...HEADclean.Full report:
/home/kitt/lanes/REPORT-board62-fix4.md(not part of this repo).Fix round 5 (this push)
A round-5 adversarial review found 1 P1 and 2 P2s, and made an observation
that changed the plan: every round so far (1-4) shipped a bounded,
PER-SIGNAL freshness approximation (a scan-root directory mtime, an
account-level top-K of recorded files, a codex day/month/year chain, …),
and every round found one more write shape that approximation could not
see — an append to the 11th-newest file in a project, a limit landing in a
subagent's
agent-*.jsonl, a rollout appended to after publish from adifferent cwd. A bounded guess about which paths to watch is always one
write shape behind reality.
Design decision: stop guessing.
board_staleno longer keeps aper-engine catalogue of "the paths that might have changed" at all. It
re-lists EVERY transcript file under the tank (the exact
findper engineboard_state_refreshalready ran to build the generation) and re-statsevery one of them in a single batched call, on every read, reducing the
whole set to one opaque fingerprint (count + a CRC over each file's own
path\037mtime_ns\037size, sorted). Equality between that, recomputedfresh, and what publish last recorded is now the ENTIRE staleness signal,
for every engine. This is real O(files) work again — but it is
find+statonly, never a file open or a parsed line: #62's actual cost wasPARSING (a title/recap read, a rate-limit scan) on every frame, and stat
never did that. Measured on this round's host: the raw
find+statforksover 500 transcripts run in a few milliseconds each; the full freshness
check (which also pays a bash-level loop building the file-list array)
costs about 35ms for the same 500 files — comfortably under the 50ms bar,
though most of that 35ms is bash's own per-line loop overhead, not the
stat syscall (see the fix report for the breakdown).
Deleted outright rather than left beside the new signal (all superseded,
none of it partially — see the diff, not a summary of it): the per-project
top-K recorded-file set,
_claude_usage_staleand its three sub-signals,every per-engine "scan root" mtime/chain (
_board_scan_root,_codex_newest_chain), thescanroot-mtime/claude-usage-root-mtime/claude-usage-files/claude-usage-projdirson-disk records, and theMISSING-sentinel handling that existed only to make that cataloguesurvive a not-yet-existing directory. The bounded per-file READING cache
(
reading_cache.sh) is untouched — that is where the 12x speedup camefrom, and nothing about staleness detection changes what gets parsed once
a rebuild actually triggers.
Findings fixed:
running was invisible to a board opened from any OTHER cwd, indefinitely
— codex's limit can run for weeks, so this could hide a dry tank for the
rest of that time). codex's only per-render signal used to be a
day/month/year directory chain keyed to the OBSERVER's
$PWD; an appendbumps no directory's mtime at all. The new whole-tank fingerprint covers
every rollout under
sessions/, regardless of which cwd is rendering.board_generationmemo in the PARENT process,so an interactive TUI's later refreshes — after
c/m/n/a/d/l—reused the FIRST refresh's generation forever, until the process exited).
Round-4 primed
_BOARD_GEN_CACHEonce per render for good reason (onebatched check per tank instead of one per render SECTION), but never
cleared it first, so priming again on the SAME long-lived process just
re-confirmed the stale memo.
_home_refreshnow clears_BOARD_GEN_CACHEbefore priming, so every refresh asks fresh.session in its project, AND any append to an
agent-*.jsonlsubagenttranscript — both read as a false green
●fuel dot until the account'snext unrelated write rescued them). Superseded by construction: the new
fingerprint covers every
.jsonlunderprojects/, includingagent-*, not just each project's newest 10.Every round-5 receipt re-run and added as its own bats case in
tests/bats/home-bounded.bats(all four failed against round-4'sa578aa0before this fix, confirmed by temporarily reverting just thethree changed files and re-running; all four pass now):
agent-*.jsonl(P2-2)_home_refreshcalled twice in one process picks up a session addedbetween the two calls (P2-1)
home-bounded.bats's own hard-failingfindshim (asserting a warmrender never lists the tree at all) is retired along with the mechanism it
was guarding — replaced with a logging shim asserting the NEW invariant:
find/statare expected and bounded (once per tank), buthead/tailnever touch a transcript's content on a warm read.
Re-measured headline (round 5)
Through the real
bin/clikaefront door,CLIKAE_NO_INTERACTIVE=1, onethrowaway store per tree,
mktemp -dHOME+CLIKAE_HOME:#62's own shape (3 claude sessions × 60MB + 970
agent-*.jsonl, 187MB,one tank):
~4.8x over main once warm, consistent with round-5 review's own 4.4x
finding on the same shape. Round-3's PR-body headline number (0.61s warm,
on a 500-agy + 50-claude / ~56MB store) is the number this round is held
to: 216ms is well within 1.5x of it (0.915s ceiling).
Full
bats tests/bats: rc=0, 1207/1207 (78 files).shellcheck -S warningrc=0 on every changed file.git diff --checkclean.Full report:
/home/kitt/lanes/REPORT-board62-fix5.md(not part of this repo).Fix round 6 (this push)
A round-6 adversarial review found 2 P1s and 1 P2, all in fix5's whole-tank
batch-stat fingerprint. Round-5's design decision — one (path, mtime, size)
fingerprint over every transcript is the entire staleness signal — was
correct and stays unchanged; these were bugs in how that fingerprint was
computed and how a rebuild acted on it.
statargv;past ARG_MAX
statdied E2BIG, the error was swallowed by2>/dev/null,and the fingerprint silently collapsed to a file count — an append to an
existing file then changed nothing a render could see). Linux's default
ARG_MAX hits this around 14k transcripts at typical path lengths; macOS's
smaller 1 MiB argv hits it around 7k.
_board_stat_rowsis now a singlefind … -exec stat … {} +piped straight intosort | cksum—findbatches its own argv per the real ARG_MAX, so this failure mode is now
structurally impossible, not just less likely. The same argv exposure at
sessions_by_mtime's call site (building the cold-build sid/scope list)is also removed: the incremental path never calls it, and the cold-build
path now sorts from stat data it already has instead of re-stating via argv.
while readloop pullingfind'soutput through a process substitution, one byte at a time — 500-575ms at
5,000 files even when
statsucceeded). Gone along with the argv itself:_board_stat_rowsnever pulls a path into a bash variable.every file's sid/scope/resume-row recomputed, every file's rate-limit
reading re-scanned, even though only one file changed — 39.8s for one
append in a 5,000-session store, 5.5s on home: board render time must not scale with transcript count or size #62's own shape). This is
exactly the regression class home: board render time must not scale with transcript count or size #62 exists to fix: the board is designed to
be opened mid-session, so a tank actively being used almost never saw the
"near-instant warm" render home: board render time must not scale with transcript count or size #62 promised.
board_state_refreshnow diffsthe fresh stat rows against the previous generation's own manifest via
ONE
awkpass (not a bash loop) instead of rebuilding everything;sids/and
recent/start as acp -al(hard link, no data copy) of theprevious generation; only a changed, new, or removed file pays a fresh
parse. A cold build (nothing to diff against yet) keeps the prior bulk
shape.
No new staleness signal was introduced —
board_stale's equality checkagainst the single fingerprint is untouched, and no per-project top-K,
-mminwindow, scanroot sentinel, or day-dir chain was reintroduced.Three new bats receipts in
tests/bats/home-bounded.bats: a 20,000-transcriptfixture (built in the test) proving the fingerprint still sees a one-line
append at that scale, with the underlying
find+statpipeline asserted toreturn rc=0 and produce no stderr (no more
2>/dev/nullhiding a failure);a stubbed-parser test proving an incremental rebuild re-reads exactly the one
file that changed, not the other 49; and a deletion test proving a removed
transcript's resume row disappears on the next rebuild (the one new failure
mode the carry-forward design introduces).
Re-measured headline (round 6)
Through the real
bin/clikae,CLIKAE_NO_INTERACTIVE=1, one throwaway storeper tree, warm best-of-3. Stores: 500 and 5,000 small claude sessions (all
but one backdated 30 days outside the rate-limit window — a realistic
"mostly old, one active" tank), and #62's own shape (3×60MB + 970 agent
files).
No-write warm:
bf5078c)One file appended, then rendered (the shape P1-2 is about):
The 5,000-file append case — the worst of the three — went from 39.8s to
0.7s, a ~57× improvement, landing within 2× of main instead of 100×+ over
it.
strace -f -e trace=openataround the render confirms exactly one.jsonlfile is opened after the append, in both the 500- and 5,000-filestores, regardless of the other files present.
Full
bats tests/bats: 1209 ok / 1 not ok / 1210 total. The one failure(
roam.bats, a tmux second-attach terminal-width assertion, unrelated toboard_state.sh) reproduces identically on the pristine pre-round-6baseline — pre-existing and environment-specific, not a regression from
this round.
shellcheck -S warningrc=0 on the changed file, with andwithout
-x;git diff --checkclean.Full report:
/home/kitt/lanes/REPORT-board62-fix6.md(not part of this repo).Fix round 8 (this push)
A round-7 adversarial review found 2 P1s, 3 P2s and 4 P3s. All nine are fixed,
one commit each, plus two more that this PR's own CI and its own timing found
while fixing them.
#62's acceptance condition is now MET, not reframed. Rounds 6 and 7 called
cold "not a target" while the issue says "Board render on the machine above
under 1 s cold and near-instant warm". On an idle host, 5,000 transcripts:
cold 415 ms (was 35,631 ms), no-write warm 1.22x main, one-append 1.93x
main. Table below.
f797845).cp -alcarriedsids//recent/forward as HARD LINKS and two in-place writes went throughthem, so publishing gen2 rewrote gen1 while
currentstill pointed at it;the same copy cost one
link()per transcript PRESENT (5,001 at 5,000files). A generation now holds only what changed in it plus a
parentpointer; readers walk that chain, bounded — it materialises a real
cp -abefore depth 8 — so reads are <= 8 lookups and writes are proportional to
CHANGED files. Removal writes a zero-byte tombstone; every entry write is
temp +
mv -f; GC (here and inclikae clean, now sharing one rule) neverunlinks a link of the live chain.
5e9cfb8).The publisher and the freshness check hashed two different byte streams that
could only agree when the tank held at least one file, so every freshly
clikae init-ed tank — the first screen a new user sees — published a newgeneration on every frame, forever, 2.2x slower than main at doing nothing.
One fingerprint function now, called by both sides.
7acb51c). The cost was never the tree walk; it was~4 forks per FILE. The entry name is computed without a process, claude's sid
comes from the filename and codex's/grok's from ONE batched bounded read
(
head -c 512overxargs -0batches, with a per-file fallback so the boundcosts speed and never an answer), and the rate-limit scan is bounded to the
newest
CLIKAE_HOME_RECENT_MAXfiles per project directory.94cb3f1). Removals areapplied before changes; a file seen at a new path was being written and then
deleted under the same key.
d50cb1c): the compat gate's negative control now calls thereal ruler instead of a copy of it;
_agy_ws_loadclears what it loaded andis namespaced by tank;
_board_transcript_findno longer swallows a per-filestatfailure; grok's same-second same-sizesummary.jsonrewrite isdocumented where the file that has the problem is named.
a590006)._board_entry_key(bash)and the cold build's
gsub(awk) were two spellings of one rule anddisagreed on a non-ASCII sid — a MISS, i.e. a session silently absent from
clikae resumewith its transcript still on disk. A tank with Chinese oraccented project paths would have lost its whole Resume list on macOS. One
implementation now, run byte-wise (
LC_ALL=C) on both sides.26cac7b). One-append came out at2.15x, over this round's 2x bar. Breaking the rebuild down rather than
guessing: 26 ms of 127 ms was the tree walk, paid twice — once by
board_staleto decide, once byboard_state_refreshto do it. The checkhands its rows over now.
Measurements (round 8)
Idle host (16 cores;
busy_coressampled from/proc/stat— this box'suptimeload average is container-scoped and read 0.59 whilevmstatshowed8 of 16 cores busy, so it is not a usable idleness signal on its own). Same
fixture and harness shape as the round-7 reviewer's. Wall clock, min of 3
(cold is one sample);
main=origin/main4496a6d.Marginal cost of one changed file (
append - warm, immune to a load offset,which is the gate the round-7 reviewer asked for instead of a ratio):
94 ms at 5,000 (round 7: 238 ms) and 73 ms at 500 (round 7: 86 ms).
Second idle run reproduces within noise: 5,000 cold 403 ms, no-write 1.18x,
one-append 1.90x.
strace -f -e trace=link,linkatover a one-append render at 5,000 files:0
link()calls (round 7 measured 5,001), and exactly one.jsonlopened.Full
bats tests/batslocally: 1,319 ok / 1 not ok, the one beingroam.bats's tmux width negotiation, which reproduces 2/2 onorigin/mainin the same window (and passes on both CI platforms) — a host flake, not a
regression.
shellcheck -S warningrc=0 on every changed file (-xtoo);git diff --checkclean;git merge-treeagainstmainclean.Full report:
/home/kitt/lanes/REPORT-board62-fix8.md(not part of this repo).Fix round 9 (this push)
A round-8 adversarial review found 2 P1s, 2 P2s and 4 P3s. All eight are
fixed, one commit each.
The headline correction first. Round 8's "cold 415 ms at 5,000 files" was
measured on a tank with ONE project directory and one in-window transcript,
and the acceptance it claimed depended on a bound that could not hold: the
rate-limit scan was bounded by a COUNT (the newest
CLIKAE_HOME_RECENT_MAXfiles per project directory) while the thing that count approximates — the
rolling limit window — is a TIME. Different dimensions, so the reviewer was
right that no count can ever be "provably >= the window", and they measured
both halves of the consequence: a dry tank drawn as full on the real front
door, and cold rising to 1,453 ms as soon as the in-window files were spread
over ten project directories.
Window semantics, stated plainly. Every transcript whose mtime is inside
the engine's window (claude 300 min, codex 7 days) is scanned for a limit
signal, no matter how many share a project directory.
CLIKAE_HOME_RECENT_MAXbounds the Resume rows a scope SHOWS and nothingelse. The cost is bought back by not forking per file rather than by looking
at fewer files: one
tailperxargsbatch plus oneawk, over exactly thebytes (
$CLIKAE_TX_TAIL_BYTES) the per-file parser would have read.with project-directory count (
3d442ab). The bound is the window now._LIMIT_READING_AWKis one spelling of the matching rules, shared by theper-file parsers and the new batched
_limit_batched_readings. Fourreceipts, all red on
26cac7b: a claude limit in a session that is not thenewest in its project (behind
RECENT_MAX+2 newer neighbours, never writtenagain — what an abandoned dry session leaves on disk); the codex twin in a
day directory; a cold build that scans all 40 in-window files, none of the 5
outside, in <= 5
tailprocesses; and the batched scan agreeing with theper-file parser on both engines, including a transcript with no trailing
newline.
home-bounded.bats:345— the round-5 receipt that APPENDED thelimit, which made that file the newest and so could not tell a count bound
from a window bound — is replaced by the first of those (round-8 P3-4).
(
3de35fc). Round 8 folded every byte outside[A-Za-z0-9._-]to_, and_board_scope_rawhands every engine but claude the raw$PWD, so twosibling directories of equal BYTE LENGTH shared one
recent/entry —專案一listed專案二's sessions and專案二listed none. The name is aninjective
%XXescape now, so the class is gone rather than rare; and thecold build groups
recent/by the RAW SCOPE (the incremental merge/purgeverify the
#scopeheader) so that even a truncation collision degrades toa MISS, which is what this file's header has always promised.
Deviation from the round-9 brief, measured: the brief asked for
cksumofthe raw bytes. The reader can call
board_key, but the cold build namesevery entry inside one
awkpass, soawkneeds its own CRC-32 — written,verified byte-exact against
cksumon ASCII and CJK, and timed at 177 msfor 5,000 sids (gawk, three runs) against a cold build that must fit in
one second and costs ~400 ms without it. The escape costs 3 ms on the same
input and needs no second implementation.
(
0fe0112). Protection was drawn aroundcurrentonly, andcurrentisnot the only generation being read:
board_generationmemoizes a generationpath for the life of the process. keep-N counts CHAINS now — the newest
keepgenerations plus whatevercurrentpoints at are roots, and everyancestor any root resolves through is protected with it. Receipt (red on
26cac7b): hold a depth-7 generation, publish once more, all 50 entriesstill resolve from the held generation and every link of its chain is on
disk —
clikae clean's sweep included, since it shares the rule.3d442ab,d396968): the comment describing areadings-pendingmechanism that had not existed since round 8 is gone(
grep -rn readings-pending lib bin testsis empty);readings-boundedispublished by BOTH the cold and the incremental path, with one meaning on
both; and
scripts/test.sh— which readfind lib tests scripts -name '*.sh'and so LOOKED like it coveredtests/— now scans.batstoo, witha matching CI step, because the action does not scan
.batseither. Thatgate found 73 findings across 20 files and all of them are fixed. Two were
dead assertions rather than style:
! declare -F …and! grep -q …inburn.batsnever fail a bats test (SC2314), so both had been assertingnothing.
Measurements (round 9)
Idle host, 16-core Linux;
busy_coressampled from/proc/statimmediatelybefore and after every row (this box's
uptimeload average iscontainer-scoped and is not a usable idleness signal on its own). Every row's
busy_coreswas between 0.08 and 1.44. Front door (bin/clikae), wall clock.main=origin/main9cf7a29. Fixture:<files>transcripts of two JSONlines spread over
<dirs>project directories, 200 of them inside thewindow, the rest backdated 30 days.
Every branch row was verified to have actually scanned what it claims: the
published
readings-boundedlist was counted immediately after the coldrender and equals the in-window count exactly (200/200 in this table). A first
pass of this same harness quietly put only ONE file inside the window — the
numbers looked fine and meant nothing, so the count is now part of the
measurement.
#62's "under 1 s cold" holds — and now it holds on the shape that broke it.
The reviewer's own P2-1 fixture (5,000 files, 10 in-window per project
directory), same harness, same host:
Cold is flat in the number of project directories now, because the cost is no
longer ~10 ms per reading. Pushing it further on purpose: 20 project
directories and 1,000 in-window readings — 452 ms cold (all 1,000 scanned,
verified). The old mechanism's own slope puts that at roughly ten seconds.
Full
bats tests/batslocally: 1,323 ok / 1 not ok, run in five chunks,each chunk taking the same lock
scripts/test.shtakes (neverCLIKAE_ALLOW_CONCURRENT_SUITE); the chunk lists were reverse-checked againstthe full 84-file list (byte-identical union, no file in two chunks). The one
red is
roam.bats's tmux width negotiation (SECOND_WIDTH 100where 60 isexpected), and this round has the interleaved control round 8 could not get:
the SAME chunk on
26cac7band on this HEAD, alternating, in one window —26cac7b red 2/2, this HEAD red 1/2. Load-sensitive host flake, not a
regression, and green on both CI platforms.
shellcheck -S warningrc=0 overbin/clikae,install.sh,scripts/test.shand every
.shAND.batsunderlib/ tests/ scripts/(-xclean on thechanged library files). bash 3.2 (
docker run bash:3.2):bash -noverbin/clikae+lib/**+scripts/*.sh, a realsourceof the board/limitstack, and
_board_entry_keyover an ASCII/CJK/accented/edge corpus — allgreen.
git diff --checkclean.Correction to what this section said when it was written. It carried a⚠️
saying
git merge-treeagainstorigin/mainwas NOT clean because#86conflicted in
CHANGELOG.mdandlib/commands/burn.sh. That was resolved inthis branch by two merges —
fb91dcd(9cf7a29, #86) and3c1b4f2(
90bf75b, #92) — and by the end of round 9bmerge-tree --write-tree origin/main 2f51d15returned rc=0 withmergeable_state = cleanandHEAD..origin/mainempty. The warning is removed rather than left standing:a merger who reads "this does not merge" stops, or does work that is already
done. For the state as of THIS push, see the round-10 section below.
Full report:
/home/kitt/lanes/REPORT-board62-fix9.md(not part of this repo).Fix round 9b (pushed after round 9's review)
Round 9 went green on Linux and red on macOS, and the red was not a flake.
Four commits.
02d8f89— same-scope is BYTE equality, because Apple's awk answers!=with
strcoll().bats (macos-latest)test 720 failed on round 9's ownreceipt: the
專案一scope's Resume list held專案二's two sessions aswell. The escape (round 9's P1-2 fix) was innocent — Apple's awk computes it
byte-exactly, and golden-name asserts on the runner now pin that. The merge
happened one step later, in the awk that groups the cold build's
recent/rows by scope:
if (sc != cur) { …start a new entry… }. POSIX makes awk'sstring comparison follow the caller's COLLATION, and macOS' awk really does
(
apple-oss-distributions/awk,src/run.c,relop()isstrcoll(), for==/!=as much as for<). That awk ran withoutLC_ALL=C, the runner'scollation is UTF-8, and macOS' UTF-8 collation has no weights for most CJK
ideographs — so
專案一 != 專案二was FALSE, the scope never "changed",and one directory swallowed the other's rows.
No Linux awk can show this and no glibc locale can either (glibc's
strcollnever returns 0 for two distinct strings; gawk/mawk/onetrue-awk byte-compare
regardless of locale), so it was reproduced with the real thing: Apple's awk
built from source in a container, under a compiled locale that gives
二theweight of
一. Both sites (the cold build's grouping awk, and_board_purge_recent_row's filter) now carryLC_ALL=Cand compare withbsame(a,b)—length(a)==length(b) && index(a,b)==1, which consults nocollation at all. Either alone would fix it; both are there because this
class is invisible on the platform the work is done on.
6a4aa49— the GC ranks generations in PUBLISH order, not by a same-secondcoin toss.
_board_gc_candidatessorted by whole-second mtime with thedirectory name (mktemp's random suffix) as the tie-break, so when several
publishes share a second — every rebuild in a tight loop, every bats run —
"the newest
keep" was random, and a generation a reader still HELD couldfall out of the roots and be unlinked on the very next publish. Round 8's own
depth-7 probe, re-run on round 9's tip, lost the held generation on the FIRST
publish in 2 of 4 runs.
seqis written at publish time (the previouscurrent's seq + 1), so the ranking no longer depends on the clock; mtimeand name only break a tie between two processes that published from the same
current.3bd9189— the.batsfiles merged in frommainpass the.batsshellcheck gate, and five negations now assert. Round 9 added that gate;
the merge brought in files written before it. Five
! grep …lines inclean.batswere SC2314 — a bats test cannot fail on!alone, so thosefive had been asserting nothing. They are
run ! grep …now, withbats_require_minimum_version 1.5.0.2f51d15— among same-second rows, the session that JUST changed stays onResume. The new row was appended and
sortwas not stable, so a renderimmediately after a write could drop it. The row goes first and the sort is
sort -s. (_board_merge_recent_rowalready skips an existing row with thesame sid, so leading with the new one cannot duplicate it.) This commit also
hoisted
_board_gc_rowsout of a$( … )— see round 10's P3-2 below forwhat that was really fixing.
Result:
bats (macos-latest)1719/1719,bats (ubuntu-latest)1719/1719, allnine checks green on
2f51d15.Fix round 10 (this push)
A round-9 adversarial review returned 🟢 mergeable with 0 P1, 2 P2 and 3 P3.
Three commits.
P2-1 — a
==> … <==line INSIDE a transcript was read as atailbanner(
dbe8f04).tail's framing is in-band: its==> name <==separatorstravel in the same stream as the file CONTENT that
_limit_batched_readingsfolds, and the scan matched them by PATTERN. One physical line shaped like
one — a torn or partial write, anything third-party writing into the tank
tree — therefore ended that file's section early. The reviewer measured both
halves on the real front door: the limit sitting after the line was
attributed to a path that was never in the scan list, the transcript's own
reading came back EMPTY, and a dry tank drew
●where the control drew○ … resets 11pm. Since claude's_limit_tank_dry_rawdeliberately neverfalls back to
dry_store,burnwould dispatch into it — round-8 P1-1'sexact failure through a second door — and it broke this function's own
promise, asserted in its header and in bats, that it is a SPEED path and
never a narrower answer than
_limit_claude_reading.A banner is reconciled against the scan list now instead of pattern matched.
The list is read first (
NR == FNR), so the scan knows which paths areexpected and in which ORDER they were handed to
tail, and a banner isaccepted only when it names exactly the next unconsumed path — where the
batch's own framing can put one. The comparison is
bsame, never awk's==,for the reason
02d8f89had to learn. Refusing a line is not enough on itsown, though, because the batch stays mis-framed from there on, so the batched
result is TRUSTED only when three signals agree: awk saw no banner-shaped
line it did not accept (
/dev/null's own banners excepted), the pipelineexited 0, and one row came back per listed path. Otherwise the per-file
parser answers the whole list. A fabricated banner naming the very NEXT path
is invisible to a row count alone — it consumes that file's real banner later
and the totals still balance — which is why the first signal exists.
The same check covers a listed path that vanished between the scan and the
tail:tailthen prints no banner for it at all and exits 1, whichxargsturns into 123 — under
bin/clikae'sset -eo pipefailthat used to abortthe whole refresh.
Receipts, on a fixture of four transcripts (one carrying
==> /etc/passwd <==, one carrying an EARLIER sibling's path, one carrying the NEXT sibling'spath) plus a listed path that does not exist:
Cost: the process count is unchanged and still flat in the number of files —
clone/execve/tailcounts are byte-identical at N = 100 / 500 / 1,000(1062 / 518 / 78), two
grep -cmore per batched read than before.P3-2 — the board GC now has a guard that EXECUTES under bash 3.2
(
0e5f545).2f51d15fixed acasepattern inside$( … )in_board_gc_candidates, and nothing in this repo could have caught it. bash3.2 — what macOS ships and what
bats (macos-latest)really runs clikaeunder — does not parse a command substitution's body when it parses the file;
its scanner stops at the first unbalanced
), which is exactly what acasepattern has.
bash -n,shellcheck -S warning, the CI syntax gate and everysource-scanning guard in
compat.batswere all silent, while on 3.2 thesubstitution failed at RUNTIME on stderr, yielded the empty string, and the
function returned 0 — the GC swept nothing on macOS and reported success:
The new
compat.batstest runs that on a real generation chain. Itskipswith the reason when docker or the
bash:3.2image is absent (it neverpulls, so CI's macOS runners simply skip it) and it carries a control — a
known-bad
$( … )in its own file that must fail inside the container — sogreen means "fixed", not "quietly ran on bash 5".
tests/README.mdgains thesection naming the class, the rule that follows from it (hoist the
caseinto a helper; a guard for it must execute), and the trap that cost the
review an experiment:
bash:3.2is an Alpine image, so busybox'sstat -f '%m'answers with FILESYSTEM information andfile_mtimereturnsgarbage there — the container's userland, not this code.
P3-3 —
scripts/doc-names-exist.sh's own "I found no source" insurancecould never fire (
2b53f98).${#SRC[@]:-0}is not a legal expansion, soboth call sites printed
bad substitutionon every run and the tests theyguard evaluated to failure — which is how a gate that exists to refuse a
green light meaning "I did not look" gave exactly that:
This file is not this PR's —
e226308introduced it onmainand it wasbyte-for-byte identical to
origin/mainuntil this commit. It is fixed herebecause the branch's own review ran the gate and the noise was in the
receipt.
P2-2 — this PR body. Rounds 9b and 10 are written down above, and the⚠️ claiming the branch does not merge is corrected in
round-9 section's
place. Four commits (
02d8f89,6a4aa49,3bd9189,2f51d15) had norecord here at all, including the one root cause that only macOS can produce.
P3-1 · the honest cost statement (no code change)
Round 9's tables measure cold against the NUMBER of in-window transcripts, and
by that measure #62's "under 1 s cold" holds with room to spare (5,000 files /
40 project directories / 400 readings = 439 ms; all 5,000 in-window = 471 ms).
That is not the whole cost function. Every in-window file is read up to
CLIKAE_TX_TAIL_BYTES(512 KB), so cold is linear in in-window BYTES, and onlarge transcripts it is seconds, not milliseconds. Same host, same harness,
1,000 × ~620 KB transcripts (605 MB), all in-window, 10 project directories:
2f51d15)main90bf75bThis is not a regression — cold is within 1% of
main, andmainpays iton EVERY render while the branch pays it once and is then ~12× faster. But the
acceptance sentence should be read with its unit: under 1 s cold for a tank
whose in-window transcripts are a few tens of MB, not "under 1 s cold" without
qualification. No code change; stated here so the number in the PR is the one
that is true.
Full report:
/home/kitt/lanes/REPORT-board62-fix10.md(not part of this repo).Gates and merge state (this push,
2b53f98)(Per this branch's test policy, the full suite is not run locally — six lanes
share one suite lock — so the 1719/1719 figure comes from CI, not from here.)
mainmoved twice while this round was running:b030edf(#93, antigravityresume --conversation+ tank-scoped Resume rows, issue #34) and6190f86(#87, burn reports work left behind).
git merge-tree --write-tree origin/main HEADconflicts, the API reportsmergeable_state = dirty, and that is whythis push has no CI run:
cifires onpull_request, GitHub builds thoseruns from
refs/pull/78/merge, and that ref is still the staleMerge 2f51d15 into 90bf75b— no merge ref, no run.2f51d15's 9/9 green isthe last CI this branch has.
The merge was attempted in this lane's worktree and then aborted on purpose.
The textual conflicts are small —
tests/bats/clean.bats(both sides fixed thesame SC2314 lines, different spelling),
tests/bats/compat.bats(both sidesappended a test),
lib/adapters/antigravity.sh(#93 deletesadapter_recent_sids' cwd filter; this branch adds aboard_recentfast pathabove it) — and they resolve in minutes. What does not resolve in minutes is
what
main's own new tests then say. With the merge resolved andshellcheck -S warningclean over everything:Both failures are this branch's board, not the merge:
#93's point is that agy'sworkspacefield is a constant onreal installs, so cwd-scoping hides every row. This branch keys the board's
recent/entries for antigravity by exactly that cwd scope(
board_state.sh:1087-1126), so the board answers with the onecwd-matching session and returns before the adapter's now-unscoped disk scan
can run. Teaching the board tank-scoped agy rows is a design change to this
PR, not a conflict resolution.
recent/rows it keeps per scope byCLIKAE_HOME_RECENT_MAXbefore#83's burn-sidecar exclusion removesburn sessions, so a tank where burns outnumber the cap (195 burns + 50
humans) publishes a Resume list that is entirely burns and renders 0
human rows.
mainhas a test that says that must not happen. Where the capis applied relative to the exclusion is a real fix, with its own receipts.
Neither is visible in this PR today, because the tests that catch them arrive
with the merge. They are stated here so that the merge is planned rather than
discovered: this branch needs one more round — merge
main, then make theboard tank-scoped for agy and cap Resume rows after the burn exclusion, not
before. Resolving the conflicts blind and pushing would have traded a red
local run for a silent regression in the merged result.
Fix round 11 — the merge, and #93's semantics adopted into the index
Round 10 stopped one step short on purpose:
mainhad moved tob030edf(#93)and
6190f86(#87) mid-round, and six ofmain's tests went red against thisbranch's board. That was never a bad conflict resolution — it was a genuine
semantic mismatch, so round 10 measured it, wrote it down, and aborted the
merge rather than push a silent regression. This round lands it.
974b2a3merge,cae6ef4agy scope,966dcb8the widened cap.1 · The merge (
974b2a3), three textual conflictstests/bats/clean.batsrun ! grep …(3bd9189, mutation-tested in round 9). Same assertion as main's! grep … || false.tests/bats/compat.batstouch -d,date -d,stat -c).lib/adapters/antigravity.shadapter_recent_sids' cwd filter; this branch added the board fast path above it$nnaming, no$wantfilter, tank-scoped scan, withboard_recentin front.git merge-tree --write-tree origin/main HEADis clean,git diff --checkclean both for the worktree and for
origin/main..HEAD.2 ·
main's six red tests, and what changed for eachThe merge commit is the mechanical resolution only. The six are two design
alignments, not conflicts, so they are two separate commits with their own
receipts.
cae6ef4— agy's Resume index is keyed by TANK, not by cwd (#34).A real agy install writes one
workspacevalue for every session (the CLI'sown root, not the user's cwd), which is why #93 deleted the cwd filter from the
adapter's disk scan. This branch's index reproduced the same bug one layer
down, and would have hidden #93's fix:
board_recentanswers fromrecent/<key(scope)>and returns first, so on a warm board the adapter'snow-correct scan never runs, and a cwd-keyed index had no rows to answer with.
Both sides of the index move together now —
_board_scope_raw(read) and_board_engine_sidscope(write) — onto one scope per tank,_BOARD_TANK_SCOPE=#tank. Not a path, so it cannot collide with a realcwd, and
_board_entry_keypercent-encodes it to the same%23tankon bothsides (verified under real bash 3.2, below).
Two things fall out, neither of them the reason:
_board_engine_sidscopenolonger opens a file for agy at all (the
adapter_session_cwdfallback fork persession is gone), and neither
board_state_refreshnoradapter_recent_sidscalls
_agy_ws_loadany more — that bulk workspace index existed to serve thecwd keying and the cwd filter, and with both gone it was one awk pass over the
tank's metadata per refresh for a value nothing reads. The helpers stay where
they are with their receipts in
tests/bats/adapters/antigravity.bats.966dcb8— the index is built at the same widened per-tank capclikae homeasks with (#93 P2-1).
The cut and the burn-sidecar exclusion are not commutative.
_home_recent_rowsdrops burn one-shots after the adapter answers, so ananswer already cut to N hands the filter N rows to drop and nothing to promote.
#93 fixed that by widening the ASK per tank (
N + _burn_tank_hidden, ceilingCLIKAE_HOME_RECENT_SCAN_MAX=CLIKAE_BURN_SIDECAR_CAP= 2000, withT_RESUME_TRUNCATEDwhen the ceiling really bites). On a warm board this indexis what answers that ask, and an entry cut to 10 at BUILD time cannot hand back
205 rows however wide the ask is. So
board_state_refreshnow computes thesame widened cap, per tank (never per store — a row this tank returns can only
be dropped by a sid recorded for this tank), under the same ceiling, behind
declare -F _burn_tank_hiddenbecause that helper lives inhome.sh. A tankwith no sidecar keeps N exactly: the common entry is byte-for-byte what rounds
5–10 wrote.
CLIKAE_RESUME_ALL=1skips the widening on both sides.The READ side is deliberately unchanged — the entry's own cap bounds the
answer. See "what is not verified" for the draft that was rejected and why.
Red → green, one bats run per step (
home.bats,home-bounded.bats,board-width.bats):So the six named in round 10 are green, and four more that only became
reachable once agy stopped being cwd-scoped are green with them. No test was
loosened, edited or skipped — the only test files this round touches are the
two conflict resolutions.
3 · This branch's own guarantees, still green
Including, by name: the window-bounded scan (
board … scans the whole WINDOW, nothing outside it, and does not fork per file), the batched-vs-per-fileagreement on both the reading parser and the sid/scope table, the injective
escape and byte-equal scope receipts (
the entry name the cold build WRITES is the one a reader LOOKS UP (incl. non-ASCII),non-ASCII sibling scopes keep their OWN Resume list), GC chain protection (GC never unlinks a generation the current one still resolves through,GC keeps the chain of every LIVE generation), publish-order GC (publishing a generation leaves the PREVIOUS one byte-identical), the same-second row (does not reuse a stale generation on its next refresh), and round 9's==> path <==banner receipt.Other gates on
966dcb8:Real bash 3.2 (
docker run --rm -v "$PWD":/w bash:3.2, image presentlocally — the compat guard ran rather than skipping,
ok 175 board GC still RUNS under a real bash 3.2), plus a direct probe of everything this roundadded:
4 · Cost on the merged head
Same fixture and same script as rounds 9/10 (
1000 × 634,800 B = 605 MB across 10 project directories, every file inside the window), A/B against thepre-merge tip
2b53f98interleaved on the same host under the same load,because this host is not idle and absolute numbers here are not comparable
across rounds:
Warm is 416–418 ms, unchanged against the pre-merge tip and well under the
~500 ms line; cold is within noise of it (and both runs of the merged head came
out below the pre-merge run they were paired with).
readings-bounded= 1000in every run: the window bound is still the window, not a count. For scale,
round 9 measured
mainat the time as ~4.3 s cold and 4.27 s on everyframe; this branch pays the cold cost once and then renders ~11× faster.
5 · What this round did NOT verify
version of
966dcb8also recorded each entry's cap in its#scopeheader andmade
board_recentfall through to the adapter's disk scan whenever the askexceeded that cap — "a speed path, never a narrower answer". It is a real
guard, and it turned two of this branch's own receipts red (
snapshot selects newest main sessions,does not reuse a stale generation on its next refresh), both of which define the index cap AS the bound on the answer.Rather than loosen them, the guard was dropped and the two caps are kept in
step at BUILD time instead. The residue that leaves: if a tank's burn
sidecar grows without any transcript in that tank changing, the entry stays
at the older, narrower cap until the next rebuild, and the Continue list is
short by that difference. In practice a recorded burn sid comes with a
transcript in the same tank, so the two move together and the drift is ~1 row
and self-heals on the next change — but that is an argument, not a
measurement. I did not build a fixture for it.
_agy_ws_load/_agy_ws_lookup/adapter_session_cwd_indexnow have nocaller on the render path — only
tests/bats/adapters/antigravity.bats.They were kept (deleting them means deleting green receipts for a documented
adapter hook, which is not this round's business), but they are dead weight
until something asks for a bulk cwd index again. Naming it rather than
quietly leaving it.
docker bash:3.2— Alpine/busybox userland, not BSD. Thestrcoll()awk root causefrom
02d8f89is still carried forward from the commit message, notre-verified on Apple's awk.
bash:3.2image is already present(it never pulls). It ran here; on CI's runners it will skip.
bats -rlocally (six lanes share one suite lock). The receiptsabove are 11 targeted files under
flock; CI is the arbiter forWindows/pester, the pty smoke and the macOS jobs.
non-interactive single render of
bin/clikae.side. They support "warm did not regress" and "the order of magnitude is
unchanged"; they are not absolute figures for the Mac in home: board render time must not scale with transcript count or size #62.
Fix round 11, continued — three more merges, and two reds that were main's
The section above was written against
main=6190f86.mainmoved threemore times while this round was in flight, so the round ends with four
merge commits, not one. HEAD is
c05b557, CI 9/9 green, PRmergeable: clean.The three later merges
4ee4186(usage: per-tank fuel from the vendor's usage endpoint; board dots and burn read it (#72) #89) — three conflicts.bin/clikae: both sides added asourceline to the same block; both kept.lib/commands/home.sh: a COMMENTblock both sides rewrote (the header that used to promise "fork-free") —
took main's, which is the superset and documents main's own new per-tank
jqcost.
tests/bats/compat.bats: both sides appended a guard; both kept.Nothing in this branch's design had to move — the board index and the usage
cache share no code path.
9f1ad57(tmux: the status row is how to get back, how much fuel, and what is red (#77) #102) — one conflict,scripts/doc-names-exist.sh, where BOTHsides had made the same fix (
${#SRC[@]:-0}is a bad substitution on bash4+/5, so the "no source/docs found" insurance could never fire — this
branch's
2b53f98, main's tmux: the status row is how to get back, how much fuel, and what is red (#77) #102 round-2 P3-6). Took main's: same change plusa comment.
3d022aa(burn: reroute never targets a lock file, sidecar, or dotdir (#61) #91) — two conflicts._home_refresh: both sides rewrote thewhole function. home: board render time must not scale with transcript count or size #62 replaced main's parallel
mktemp+ background_home_dry_set/_home_total_sessions_scanwith the board index(
board_generationpriming +board_total); burn: reroute never targets a lock file, sidecar, or dotdir (#61) #91 added aprofiles_cache_reset/profiles_cache_warmprologue on top of the OLD body.Kept this branch's body — the replacement is what home: board render time must not scale with transcript count or size #62 IS — and adopted
burn: reroute never targets a lock file, sidecar, or dotdir (#61) #91's prologue into it, placed first: the board priming walks the same
tank enumeration, so warming after it would pay the uncached store walk the
warm exists to avoid.
CHANGELOG.md: both sides' entries kept.🔴 The bounded reader IS fed by the marker-filtered tank list.
_home_recent_rowsauto-merged and needed no help — main had alreadyreconciled burn: a stray *.lock entry in the profiles directory is treated as a tank when rerouting from a dry tank #61's
tanks_for_engineenumeration with antigravity: fix agy resume --conversation bug and tank-scope the board's Resume rows (#34) #93's per-tank$_askinside that walk (its own "burn: a stray *.lock entry in the profiles directory is treated as a tank when rerouting from a dry tank #61 round-6 P2-1 (merge with antigravity: fix agy resume --conversation bug and tank-scope the board's Resume rows (#34) #93)" comment) —
and
tanks_for_engineis literallylist_all_profiles | awk, which is alsowhat this branch's board-priming loop iterates. The two cannot drift onto
different tank sets. burn: reroute never targets a lock file, sidecar, or dotdir (#61) #91's home.bats tests are green alongside antigravity: fix agy resume --conversation bug and tank-scope the board's Resume rows (#34) #93's and this
branch's, in the same run.
Two reds that were main's, fixed here rather than carried
acff289— main is red at459c981/b6e8dc1, onbatsboth platforms.#89 (the vendor usage block in
_home_fuel_dotv_compute) and #102 (which moved_human_ageintolib/core/duration.shand guarded home.sh's two call sites)landed in the same window and crossed. Reproduced on a clean
origin/mainworktree with nothing of this branch in it:
and confirmed against main's own CI log for
b6e8dc1(bats (ubuntu-latest),run 34939130397): those two, and only those two, are its failures. Fixed in
two halves — #89's call site now carries the same
declare -F _human_ageguardas the other two, on the same LINE (home.bats asserts the guard per code line);
and the three usage.bats tests that source home.sh STANDALONE now also source
lib/core/duration.sh, whichbin/clikaeloads globally and they were relyingon home.sh to define until #102 moved it. Assertions untouched, byte for byte.
home.bats' call-site count goes 2 → 3 because #89 added a third site; the guard
assertion — the substance — is unchanged.
c05b557— six shellcheck findings in main's test files, surfaced by THISPR's own gate. Round 8 added a CI step here (
find tests -name '*.bats' -print0 | xargs -0 shellcheck -S warning) becauseludeeus/action-shellcheckdoes not look at
.batsat all. main has no such step, which is why main'sshellcheckjob is green and this branch's went red: the gate is this PR's,the findings are main's (
limit-codex-status.batsSC2034ts×3;usage.batsSC2154$stderr;tmux-status.batsSC2034mt, SC1010done,SC2034
CLIKAE_BURN_LOG_RETENTION_DAYS, SC2034t27). Every fix keeps thebehaviour and every assertion byte for byte — the gate was not weakened.
Final state
Local receipts on the final tree (targeted files under the suite lock — no full
bats -r; six lanes share one lock, and CI is the arbiter):Cost, re-measured on the final head
Same 605 MB fixture and script, all three trees interleaved on the same host
under the same load (this host is not idle; absolute numbers are not
comparable across rounds):
Warm is 451–452 ms — under the ~500 ms line, and ~10.5× faster than
main, which pays 4.4–4.8 s on EVERY frame. It rose ~45 ms from this branch'sown pre-merge tip (398–408 ms); that is the cost of what the three merges
brought in, most visibly #89's per-tank
jqon the usage cache, which main'sown home.sh comment measures and names. Cold is unchanged within noise.
readings-bounded= 1000 in every run of every board tree: the bound is stillthe window, not a count.
Also not verified (in addition to the list above)
roam.bats"a second client attaches to the running tank instead ofstarting it again" went red once, on
bats (ubuntu-latest)for3d022aa,and green on the very next run with only shellcheck-comment changes between
them. It is a tmux client-resize race the test's own comment already names
("a timing guess, not a defect… intermittently on ubuntu CI, never on
macOS"). I did not fix it and did not investigate further than reading it.
maincolumn is one tree, two runs. Enough for "thebranch is an order of magnitude faster warm"; not a benchmark.
Fix round 12 (pre-merge) —
0d7c246,8373ca9,3d4a2f3Round 10's adversarial review said 不可合併 on the CI gate (0 P1, 2 P2, 3 P3)
and it was right about the residue, which was worse than this body claimed.
P2-1 — the burn sidecar is now part of a tank's freshness signal (
0d7c246)This body used to say the widened-cap residue was "about one row and it
self-heals on the next change". It flagged that as an argument rather than a
measurement, and the measurement disagrees: it does not self-heal.
CLIKAE_HOME_RECENT_MAX=10)burn's own order (transcript → render → sid)Root cause:
board_stalefingerprinted transcripts only, and the sidecar isan input to the cap, not to the transcripts.
burnrecords a session's idafter the engine process exits (
burn.sh:1106,:4288), so "transcriptappears, board renders, sid lands" is burn's own ordering, not a contrived one.
Fixed in two halves, because either alone is inert:
_board_sidecar_rowsstats this tank's sidecar file(s) and_board_tank_fingerprint(renamed from_board_transcript_fingerprint)hashes them after the transcript rows, so a sidecar write makes that tank,
and only that tank, stale. Both engine spellings —
burnwrites agy'sunder the literal
agy. The generation's fingerprint file istank-fp, nottranscripts-fp, so neither side ever reads the other's as vouching for thesame thing;
_BOARD_GEN_FORMATis deliberately not bumped, because a formatmismatch forces a cold rebuild while a missing fingerprint takes the cheap
incremental one.
recent-cap), and anincremental rebuild re-cuts every entry when it moves — from the manifest
it just published, so no transcript is opened and no sid/scope is re-parsed,
through the same one function the cold build uses. Without this, the
fingerprint half changes nothing: an incremental rebuild only rewrites an
entry for a file that changed, and a sidecar write changes no file.
Inside the rebuild the sidecar is stat'd before
_burn_tank_hiddencountsit, so the published fingerprint is never newer than the state the cap was
computed from — the other order manufactures the same bug in miniature.
Four tests, red on
03d4884and green here (tests/bats/home-bounded.bats).P3-1 — the dead bulk cwd index is deleted (
8373ca9), and four hooks were leaking (3d4a2f3)adapter_session_cwd_indexand the_agy_ws_*cache built on it had no callerin
lib/orbin/; deleted, with the two tests that only exercised themselves,and every comment that named them rewritten.
Checking the loader's
unsetlist mechanically — the review flagged one missingname — found four more optional hooks leaking across a two-adapter load
(
adapter_cwd_from_args,adapter_ephemeral_flags,adapter_mcp_config_file,adapter_tank_fingerprint). Same shape as #60's and #81's earlier drifts. Fixed,plus a gate that compares every
adapter_*definition against the list, so thenext one cannot go missing quietly. Negative control run both ways.
已知限制 (documented, not fixed)
window costs ~4.5 s cold, on branch and on
mainalike (−0.1 % / −1.2 %).Warm is the number this PR is about (~455 ms vs
main's ~4.9 s per frame),and
readings-boundedis the window, not a count. Unchanged by this round.Resume list.
_claude_project_slugmaps every non-[A-Za-z0-9]byte to-, so~/專案一and~/專案二produce the same slug — but that is ClaudeCode's own layout (the transcripts really do share one
projects/directory)and the function is byte-for-byte
main's. codex/grok/agy are unaffected.Filed as claude: two equal-length CJK sibling directories share one Resume list #116; not this PR's to fix.
Receipts
bats home-bounded + home + board-width + resume-hide-burn + adapters/antigravity177 ok / 0 not ok;
burn217 ok;handoff + adapters/{claude,codex,grok} + ephemeral97 ok;adapters/extra20 ok.shellcheck -S warningoverbin/clikae install.sh scripts/test.sh, every*.shunderlib tests scripts,and CI's own
find tests -name '*.bats' | xargs shellcheckline: rc=0.scripts/doc-names-exist.shrc=0.git diff --checkrc=0.git merge-tree --write-tree origin/main HEADrc=0.沒驗證什麼 (this round)
bats -rlocally (lane test policy) — CI is the arbiter.docker bash:3.2run this round.The new code's only new external call is
stat, through the existing_clikae_statvfork that already handles both spellings.statper tank that has ever run a burn, andhome-bounded's own bounded-IOreceipts are green, but the number in the table above is round 10's, not a
fresh measurement.
single render.
exactly as the review said — the mechanism is now fixed and gated either way.