Skip to content

Stop scanning the host in App::new, and once per file in --clean - #85

Merged
ndr-ds merged 3 commits into
mainfrom
ndr-ds/agentctl-refresh-test-host-fs
Aug 30, 2026
Merged

ndr-ds merged 3 commits into
mainfrom
ndr-ds/agentctl-refresh-test-host-fs

Conversation

@ndr-ds

@ndr-ds ndr-ds commented Aug 28, 2026 •

Copy link
Copy Markdown

App::new() ended with app.refresh() — a full synchronous scan of the real ~/.claude (discovery::scan_sessions plus a ps shell-out via process::fetch_and_enrich), then ledger rollups and a detached background scan thread. Constructing an App was an I/O operation, and there are ~26 construction sites.

Nobody wanted it

Two workarounds for it were already in the tree:

  • src/main.rs:823 — // Re-refresh to replace real sessions discovered during App::new(), then app.refresh(). In demo mode the constructor scanned the real host and the very next call threw the result away for generated sessions.
  • app_with_empty_data() — "bypassing the host-side session discovery that App::new() does in its constructor", then replace_data(AppData::default()). Every test using it paid for a full scan and discarded it.

So the scan was load-bearing for exactly one caller — the non-demo TUI — and pure waste for the rest.

What it cost

Measured in this sandbox, where ~/.claude is a virtiofs bind mount:

before after
a test that only constructs an App 192 s 0.00 s
the two refresh_nonblocking tests 19.7 s 0.02 s
cargo test --lib 518 s 6.6 s

The "after" numbers were taken under load average 167, higher than the 98 during the "before" run, so this is not a quiet-box artifact.

It also made refresh_nonblocking_kicks_worker_under_runtime fail outright — 238 s against its own 60 s deadline — then pass at 75/38/32 s in isolation. That deadline had already been widened once. CI never caught it because CI's ~/.claude is empty.

The bug the constructor was hiding

run_clean's --finished filter built an entire App per JSONL file, inside a doubly-nested loop, purely to ask whether one path belonged to a live session. With 4577 transcripts on this box that is 4577 full host scans and 4577 ps forks:

$ timeout 120 agentctl --clean --finished --dry-run   # before
  …processed 15 of 4577 files, then hit the timeout   (~7.5 s/file → ~9.5 hours)

--clean --finished was effectively unusable. Both liveness questions now come from the one scan run_clean already performs:

$ time agentctl --clean --finished --dry-run          # after
  Dry run: would remove 0 sessions + 224 transcripts, freeing 424.0 MB
  7s

The predicate is unchanged — any(|s| s.jsonl_path == file_path) becomes set.contains(&file_path) over the same session list. The one semantic difference: liveness is now sampled once at the start rather than re-sampled per file. Since the sweep went from ~9.5 hours to 7 s, that snapshot is far fresher than what the per-file rescan actually delivered.

The change

  • App::new() constructs and nothing else. (It still reads parked.json — that is one small file, and the doc comment says so rather than claiming the constructor is pure.)
  • App::with_host_state() is exactly today's App::new() — scan, rollups, background scan, initial row selection.
  • Every production site that reads data_snapshot().sessions moves to with_host_state(): main.rs TUI startup and five in commands.rs. No production behaviour changes.
  • make_app's demo branch keeps the plain App::new(): it already overwrote sessions and the ledger fields with ..AppData::default(), and apply_filters — which make_app calls — is what actually establishes the selected row, not the constructor.
  • App gains refresh_worker: fn(Vec<AgentSession>) -> RefreshIoOutput, defaulting to do_refresh_io. The two tests that exercise scheduling and channel plumbing — not discovery — inject a trivial in-memory worker, so they no longer touch the host. Their 60 s deadline becomes a 5 s hang guard, which is what it always should have been.
  • RefreshIoOutput derives Default so that injected worker is one line.
  • app_with_empty_data() is deleted: with a pure constructor it was a synonym for App::new() whose replace_data(AppData::default()) had become a no-op.

Verification

cargo clippy --all-targets -- -D warnings clean; 767 lib tests pass.

Review found three things, all fixed above: the --clean loop, a doc comment claiming the constructor touches nothing when it still reads parked.json, and the stale helper comment. The --clean fix is verified end to end by running the real binary against 4577 files, not by reasoning.

@ndr-ds ndr-ds changed the title Stop scanning the host in App::new Stop scanning the host in App::new, and once per file in --clean Aug 29, 2026
@ndr-ds
ndr-ds marked this pull request as ready for review August 30, 2026 01:59
@ndr-ds
ndr-ds merged commit 40137a7 into main Aug 30, 2026
9 of 10 checks passed
@ndr-ds
ndr-ds deleted the ndr-ds/agentctl-refresh-test-host-fs branch August 30, 2026 01:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant