Skip to content

perf(collectors): stop reading the same files twice in one cycle - #243

Merged
gcko merged 2 commits into
mainfrom
perf/drc-4276-4277-collector-reads
Aug 29, 2026
Merged

perf(collectors): stop reading the same files twice in one cycle#243
gcko merged 2 commits into
mainfrom
perf/drc-4276-4277-collector-reads

Conversation

@gcko

@gcko gcko commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Implements DRC-4276 — The Codex store is globbed and stat-swept twice per collect, and the newest tails are parsed twice.
Implements DRC-4277 — Four discover() bodies use a full sorted glob as a boolean, then collect() repeats the same glob.

Two tickets, one PR, because they share a theme (stop the collectors doing the same filesystem work twice per cycle) and, more practically, they are the two claimants on the same design-doc table. AGENTS.md § Calibrating Effort owns PR grouping — "PRs are units of merge risk" — and this is the grouping that lets one author edit those two rows once instead of conflicting over them.

DRC-4276 — Codex

Application.collect calls spec.collect then spec.usage back to back. Both reach analyze_codex_transcript, which tail-reads and json.loadses every line. They overlap on any rollout that is both active and among the eight newest the quota reader samples — the ordinary case for whichever session is running right now.

codex_analysis is a cached sibling keyed on (st_mtime_ns, st_size), the same shape codex_instruction and codex_plan already use. A sibling, not a state parameter on the analyzer: it is called as a functools.partial over its config in test_transcripts.py and positionally in test_codex.py, so widening its signature would be a contract change for a caching detail.

Measured, isolated (bench_collect --repeat 5, reverting only the three changed files to main for the baseline):

Codex collector
main 4.93 ms, 5.08 ms
this branch 3.53 ms, 3.48 ms

What it does not save. The dedup removes exactly |{active} ∩ {8 newest}| tail reads per cycle, at most eight. collect analyzes only active rollouts; usage analyzes the eight newest unconditionally. On a store where three of the eight newest are inside the window it saves three reads. The ticket's 11.1 ms is the cost of the eight reads, not the delta.

heapq.nlargest replaces sorted(files, reverse=True)[:8], which ordered 516 entries to keep 8.

Left alone: the two full-store globs. Both do sort — glob_stores delegates per root to glob_under, which is sorted(glob.glob(...)) — so the ticket's count of two sorts was right even though it located them in one place. Neither is removable without changing glob_under, which every collector shares.

DRC-4277 — discovery

Five collectors used a sorted match list as a boolean, and collect() walked the same tree again moments later.

Five, not the four the ticket named. collectors/opencode.py has the same bool(glob_stores(...)) in its own discover. The ticket missed it, and a fix that left one behind would have been the one to come back. pi.py is not a candidate — its discovery goes through _session_paths, which needs the paths.

The cost is not measured, and the ticket says so. Droid, Gemini, Antigravity, Cursor and OpenCode stores are all absent on this machine, so the duplicate walk costs nothing locally. What is provable by reading is the duplication; on a machine with real history it doubles whatever that walk costs.

So the contract test is the point, more than the milliseconds. DiscoveryCostContractTest AST-walks every collector's discover and fails if one reaches for a sorting reader. CONTRIBUTING.md told a new collector's author that discovery may be "a glob_under() call", which is how this got in; it now names the probes.

Verification

Written test-first, both red before and green after:

  • DRC-4276 red testCodexReadBudgetTest.test_a_rollout_both_readers_want_is_tail_read_once counts io.read_tail calls across one collect + usage pair. Before: 2 != 1 on a two-element list. After: green.
  • DRC-4277 red testsAnyGlobUnderTest, three cases, all AttributeError before the helpers existed. The primary counts iglob consumption and asserts 1, so a naive "drop the sorted()" fix still fails at 3. It captures real_iglob before patching; resolving glob.iglob inside the wrapper is infinite recursion and would have stayed red against a correct implementation.
  • Contract test proved to bite — restoring droid's old bool(glob_stores(...)) body fails it with droid.py discover() calls ['glob_stores']; restoring the fix passes.
  • Full pre-PR suite from AGENTS.md § Pre-PR Checks: 2,011 dashboard tests and 192 script tests green, coverage 90.3% against a threshold of 73. ruff, ruff format --check, mypy --strict (113 files), lint_embedded.py, validate_plugins.py, bump_version.py --current all clean. No version field moved, checked against the merge base.
  • sync-docs reconciliation: the state.py row of docs/design-runtime-architecture.md hard-codes a cache count and classifies each cache by validity rule, and nothing in CI checks it — a nineteenth cache would have made it silently wrong. Corrected 18→19, 6→7, 4→5. The io.py row now distinguishes the sorting readers from the existence probes.

For the reviewer

Two things worth the attention:

  1. analyze_codex_transcript keeps its exact two-argument signature. That is deliberate and load-bearing — test_transcripts.py partials over it and test_codex.py calls it positionally.
  2. The cached dict is shared, not copied. Every reader in a cycle now holds the same object, so a consumer that mutated it would poison the entry rather than its own copy. The docstring says so; no current caller mutates.

gcko and others added 2 commits August 29, 2026 18:27
`Application.collect` calls `spec.collect` and then `spec.usage` back to back.
Both reach `analyze_codex_transcript`, which tail-reads up to `tail_bytes` and
`json.loads` every line it finds. They overlap on any rollout that is both
active and among the eight newest the quota reader samples, which is the
ordinary case for whichever session is running right now.

`codex_analysis` is a cached sibling keyed on `(st_mtime_ns, st_size)`, the same
shape `codex_instruction` and `codex_plan` already use. A sibling rather than a
`state` parameter on the analyzer: it is called as a `functools.partial` over
its config in test_transcripts and positionally in test_codex, so widening its
signature would be a contract change for a caching detail.

What this saves is the intersection, not the whole read. `collect` analyzes only
active rollouts; `usage` analyzes the eight newest unconditionally. The dedup
removes exactly the files in both sets, at most eight per cycle. On a store
where only three of the eight newest are inside the window it saves three reads.
Do not read the ticket's 11.1ms as the delta.

`heapq.nlargest` replaces `sorted(files, reverse=True)[:8]`, which ordered 516
entries to keep 8.

Left alone: the two full-store globs. Both do sort - `glob_stores` delegates per
root to `glob_under`, which is `sorted(glob.glob(...))` - so the ticket's count
of two sorts was right even though it put them in one place. Neither is
removable without changing `glob_under`, which every collector shares.

The design doc's cache arithmetic is corrected in the same commit. Its `state.py`
row counts the caches and classifies them by validity rule, and nothing in CI
checks that count, so a nineteenth cache would have made it quietly wrong.

Implements DRC-4276.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
`io.glob_under` is `sorted(glob.glob(...))`. Five collectors called it, or
`glob_stores` over it, purely as a truthiness test, and `Application.collect`
runs `spec.discover` in the guard immediately before `spec.collect`, which walks
the identical tree again in the same pass. So the store was globbed and sorted
twice to publish one row.

`any_glob_under` and `any_glob_stores` sit beside the readers they mirror and
stop at the first match. The four collectors that already had the cheap idiom
show the shape: claude, codex and copilot use `any_store_dir`, goose uses
`existing_stores`.

Five, not the four the ticket named. `collectors/opencode.py` has the same
`bool(glob_stores(...))` in its own `discover`; the ticket missed it, and a fix
that left one behind would have been the next one to come back. `pi.py` is not a
candidate: its discovery goes through `_session_paths`, which needs the paths.

The cost is not measured here and the ticket says so. Droid, Gemini, Antigravity,
Cursor and OpenCode stores are all absent on this machine, so the duplicate walk
costs nothing locally. What is provable by reading is the duplication itself; on
a machine with real history it doubles whatever that walk costs, and the two-level
Claude glob measures 11.3ms for 3,805 files as a sense of scale.

The contract test is the point of the change, more than the milliseconds. It
AST-walks every collector's `discover` and fails if one reaches for a sorting
reader, so the idiom cannot return in the next collector. Verified by restoring
the old droid body and watching it fail.

CONTRIBUTING.md told a new collector's author that discovery may be "a
`glob_under()` call", which is how this got in. It now names the probes.

Implements DRC-4277.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jared Scott <jared.scott@variable.team>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Name                                                                  Stmts   Miss Branch BrPart  Cover
-------------------------------------------------------------------------------------------------------
cargento/skills/cargento/agy_hook.py                                     79     14     28      7  78.5%
cargento/skills/cargento/cargento_runtime/__init__.py                     0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/aggregate.py                  213      1     64      0  99.6%
cargento/skills/cargento/cargento_runtime/asks.py                       110      0     28      0 100.0%
cargento/skills/cargento/cargento_runtime/claude_data.py                305     33    142     16  89.0%
cargento/skills/cargento/cargento_runtime/cli.py                        127     14     26      3  87.6%
cargento/skills/cargento/cargento_runtime/collectors/__init__.py          0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/collectors/antigravity.py     410     41    166     25  87.2%
cargento/skills/cargento/cargento_runtime/collectors/claude.py          256     15     94     12  91.7%
cargento/skills/cargento/cargento_runtime/collectors/codex.py           101      7     38      7  89.9%
cargento/skills/cargento/cargento_runtime/collectors/copilot.py         148      6     50      2  96.0%
cargento/skills/cargento/cargento_runtime/collectors/cursor.py          276     19    104     16  90.3%
cargento/skills/cargento/cargento_runtime/collectors/droid.py            32      3      6      1  89.5%
cargento/skills/cargento/cargento_runtime/collectors/gemini.py           53      7     16      4  84.1%
cargento/skills/cargento/cargento_runtime/collectors/goose.py            89     11     28      4  87.2%
cargento/skills/cargento/cargento_runtime/collectors/opencode.py         78      6     26      2  92.3%
cargento/skills/cargento/cargento_runtime/collectors/pi.py              326     34    152     20  88.7%
cargento/skills/cargento/cargento_runtime/config.py                     189      1     20      1  99.0%
cargento/skills/cargento/cargento_runtime/diagnostics.py                 84      4     26      4  92.7%
cargento/skills/cargento/cargento_runtime/dismissals.py                 113      2     28      2  97.2%
cargento/skills/cargento/cargento_runtime/events.py                     169      0     64      0 100.0%
cargento/skills/cargento/cargento_runtime/git_status.py                  28      2      8      2  88.9%
cargento/skills/cargento/cargento_runtime/http_api.py                   525     34    176     10  93.7%
cargento/skills/cargento/cargento_runtime/io.py                         130      2     28      0  98.7%
cargento/skills/cargento/cargento_runtime/lifecycle.py                  325     15    106      6  95.1%
cargento/skills/cargento/cargento_runtime/notifications.py              174     14     60      4  91.5%
cargento/skills/cargento/cargento_runtime/observation.py                271      9     74      1  97.1%
cargento/skills/cargento/cargento_runtime/observer.py                   239     25    108     13  87.3%
cargento/skills/cargento/cargento_runtime/probe.py                       44      0     18      1  98.4%
cargento/skills/cargento/cargento_runtime/quota.py                      333      2    112      1  99.3%
cargento/skills/cargento/cargento_runtime/records.py                    256      5    114      9  96.2%
cargento/skills/cargento/cargento_runtime/sessions.py                   101      0     44      0 100.0%
cargento/skills/cargento/cargento_runtime/snapshot.py                    36      0      4      0 100.0%
cargento/skills/cargento/cargento_runtime/spacedock.py                  454     48    238     26  89.0%
cargento/skills/cargento/cargento_runtime/state.py                       66      0      2      0 100.0%
cargento/skills/cargento/cargento_runtime/stream.py                      57      0      8      0 100.0%
cargento/skills/cargento/cargento_runtime/transcripts.py                523     31    268     26  92.8%
cargento/skills/cargento/cargento_runtime/turns.py                      197     14    104     12  90.7%
cargento/skills/cargento/cargento_runtime/web/__init__.py                 0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/web/page.py                    54      0     14      0 100.0%
cargento/skills/cargento/event_hook.py                                   86      4     28      3  93.9%
cargento/skills/cargento/mcp_server.py                                  377     22    112     14  92.2%
cargento/skills/cargento/notify_hook.py                                  49     15      6      1  67.3%
cargento/skills/cargento/server.py                                        3      0      2      1  80.0%
cargento/skills/cargento/statusline_hook.py                             131     13     46      8  87.0%
scripts/bench_collect.py                                                211     10     54      6  94.0%
scripts/bench_event_latency.py                                           67     21     14      1  67.9%
scripts/bump_version.py                                                  60     12     24      5  77.4%
scripts/capture_hook.py                                                 287     30     86     11  88.5%
scripts/derive_prompt_shapes.py                                         210     16     88     14  89.3%
scripts/lint_embedded.py                                                 92      3     28      2  95.8%
scripts/validate_plugins.py                                             675    190    390     60  69.2%
-------------------------------------------------------------------------------------------------------
TOTAL                                                                  9219    755   3470    363  90.2%

Threshold: fail_under in pyproject.toml · label coverage-exception to bypass (visible in PR timeline).

@gcko
gcko merged commit bb5f41f into main Aug 29, 2026
12 checks passed
@gcko
gcko deleted the perf/drc-4276-4277-collector-reads branch August 29, 2026 10:35
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