muse code test — implementation audit, cache behavior, and documentation
muse code test — Implementation Audit, Cache Behavior, and Documentation
Background
muse code test is one of the most powerful day-to-day commands in the workspace, but its runtime is erratic and its cache invalidation rules are opaque. Sometimes it returns in under a second; sometimes it blocks for tens of minutes with no explanation. This unpredictability makes it difficult to trust the tool in practice — agents (and humans) can't reason about when it is safe to call it, when they should wait, or when a slow run signals something broken rather than just a cold cache.
The implementation lives in three layers:
muse/cli/commands/test_cmd.py— CLI entrypoint; orchestrates selection, execution, and outputmuse/core/test_selection.py— symbol-graph-driven test selector; usesCallGraphCacheandSymbolCachemuse/core/test_runner.py— pytest subprocess wrapper and result collector
The docstring in test_selection.py claims "a warm-cache run completes in <50 ms." The cold-cache rebuild path is not documented at all — neither what triggers it, how long it takes, nor how to tell the difference from the output.
Problem Statement
There are three distinct problems:
1. Cache invalidation is a black box. The cache lives in .muse/ and is keyed against committed snapshots. But it is not clear which operations bust it: new commits, muse code add, muse index rebuild, adding new import statements, or changing the set of test files. Agents trigger cold rebuilds without knowing it.
2. The slow path has no visible feedback. When the cache is cold, muse code test may spend minutes re-parsing every blob in the object store and rebuilding the call graph. There is no progress indicator, no log line saying "cache cold — rebuilding," and no estimated time. The command looks hung.
3. There is no documentation. The expected behavior, cache lifecycle, invalidation rules, and performance envelope are not documented anywhere users or agents can find them. The only documentation is the module docstring in test_selection.py, which describes the warm-cache case only.
Goal
After this issue is resolved:
- The exact cache invalidation rules are documented and verified by tests.
muse code testemits a visible log line when it enters the cold-rebuild path, including what triggered the invalidation.- A
muse code test --cache-statusflag (or similar) lets callers inspect whether the cache is warm before committing to a run. - A user-facing reference doc (
docs/code-test.md) covers behavior, performance expectations, invalidation triggers, and how to force a cache warm-up.
Phases
Phase 1 — Audit and document the cache invalidation rules
Read the full implementation of CallGraphCache and SymbolCache. Map the exact conditions under which each is invalidated. Verify against the test suite in tests/test_core_test_selection.py, tests/test_core_test_runner.py, and tests/test_test_selection_speedup.py.
Deliverables
CT_01— Written analysis (committed todocs/code-test-internals.md) covering: what the cache key is, what files it reads, what invalidates it, and the performance envelope for warm vs cold pathsCT_02— Existing test coverage gap map: which invalidation triggers have tests and which do not
Phase 2 — Fix the cold-path UX (progress feedback)
When muse code test enters the cold-rebuild path it must say so. A single logger.warning or stderr line is sufficient — users and agents need to know the command is working, not hung.
Deliverables
CT_03— Cold-rebuild path emits a human-readable log line: "⚠️ code-test: cache cold — rebuilding call graph (this may take a minute)" or equivalentCT_04—--jsonoutput includes"cache_hit": true/falseat the top level so callers can detect which path was takenCT_05— Test: mock a cache miss, invokemuse code test --json, assertcache_hit == falseand the warning line appears in output
Phase 3 — Fill invalidation test gaps
For every invalidation trigger identified in Phase 1 that lacks a test, add one.
Deliverables
CT_06— Test: new commit advances snapshot → cache is invalidated on nextmuse code testCT_07— Test: unchanged snapshot → cache is reused (no re-parse); verified by counting object-store readsCT_08— Test: new import added to a source file → call graph is rebuilt; newly reachable test is included in selectionCT_09— Test: test file added with no production changes → new test is included without full cache bustCT_10— Test:muse code index rebuildfollowed bymuse code testuses the fresh index, not a stale cache
Phase 4 — muse code test --cache-status flag
Add a lightweight flag that inspects the current cache state and exits without running any tests.
CLI
muse code test --cache-status [--json]
Output (--json)
{
"exit_code": 0,
"cache_hit": true,
"cache_snapshot_id": "sha256:abc...",
"head_snapshot_id": "sha256:abc...",
"stale": false,
"cache_age_seconds": 42,
"callgraph_symbols": 1847,
"test_functions": 312
}
Deliverables
CT_11—muse code test --cache-status --jsonreturns the schema above; exit 0 whether warm or coldCT_12—stale: trueis returned when cache snapshot ID ≠ HEAD snapshot IDCT_13—stale: falseis returned when they matchCT_14— Test: invoke--cache-statuson a fresh repo (no cache file) → graceful JSON response withstale: true, no exception
Phase 5 — User-facing documentation
Write docs/code-test.md — the canonical reference for muse code test.
Deliverables
CT_15—docs/code-test.mdcommitted; covers: how test selection works, cache lifecycle, invalidation triggers, warm vs cold performance expectations,--cache-statususage, and how to force a warm-up withmuse code index rebuildCT_16— Inline docstring inmuse/core/test_selection.py::select_testsupdated to cross-referencedocs/code-test.mdand document the cold path
Acceptance Criteria
muse code testnever appears to hang silently; cold rebuilds are always announced--jsonoutput always includes"cache_hit"- Any cache miss traceable to a specific trigger has a test that proves the trigger works correctly
docs/code-test.mdexists and covers all behavior an agent or user needs to reason about the command correctly
Out of Scope
- Changing the cache storage format or invalidation strategy (audit only, no redesign)
- Distributed or shared caches
- Windows filesystem compatibility
Resolved — all 5 phases implemented, shipped to
main/devon all remotesCommit:
sha256:ce542c4d0f6dacc9520c02d0e3378bf96bc377d118a7402f2bbc8af1749b08e0(branchfeat/49-code-test-cache-audit, fast-forward merged dev→main, pushed to local/staging/production).Phase 1 — Audit (
docs/code-test-internals.md)Confirmed both
SymbolCacheandCallGraphCacheare content-addressed per-file memoization tables (object_id → parsed data), not snapshot/commit-ID-keyed caches. There is no single "cache valid for commit X" flag — validity is per-file and implicit incache.get(file_content_hash) is not None. This is the key fact that shaped every later phase.Decision, documented explicitly: the ticket's imagined
--cache-statusschema (cache_snapshot_id/head_snapshot_id/stale) assumes a global "pinned to one snapshot" cache model that the actual implementation doesn't have. Shipped an honest alternative schema reporting real per-file warmth instead — see the "Decision" section in the doc for the full rationale. This is an audit-driven redesign of the reporting surface only; the underlying cache storage/invalidation strategy itself is untouched, per the ticket's own out-of-scope note.Phase 2 — Cold-path UX
build_forward_graph/symbols_for_snapshotgained an optionalstats: dict[str, int] | Noneout-parameter to observe per-file hit/miss counts without changing their return types (avoided touching ~30 existing call sites across the codebase).SelectionResultgainedcache_hit: bool/cache_misses: int.⚠️ code-test: cache cold — rebuilding symbol/call-graph data (N file(s) re-parsed).--json(including--dry-run --json) now surfacescache_hitin theselectionobject.tests/test_cache_status_fields.py(5 tests) — proves the mechanism, not just output (cold run → real misses + warning fires; warm run → zero misses, no warning; empty-changed-set short-circuit never touches either cache).Phase 3 — 5 cache-invalidation gap tests (
tests/test_cache_invalidation_gaps.py)read_object/parse_symbolscalls (mock-spy proof)muse code index rebuildreal CLI invocation doesn't corrupt/invalidate the warm test-selection caches (reinterpreted from the ticket's literal "rebuild invalidates stale cache" framing, since index rebuild manages a wholly separate.muse/indices/system with no relationship to these caches at all — confirmed by readingindex_rebuild.py)Phase 4 —
muse code test --cache-status [--json]New
get_cache_status()inmuse/core/test_selection.py— pure dict-lookup +os.stat(), never callsread_object/parse_symbols. Graceful on a fresh repo with no commits and no cache files (verified both via unit tests and livemuse-devruns). 5 tests intests/test_cache_status.py(CT_11–CT_14) plus 4 CLI-level tests intests/test_cmd_test.py.Bonus finding — a real, previously-undetected bug closed while building this:
muse code test's CLI path (test_cmd.py) never persistedSymbolCacheto disk on a real run — onlyCallGraphCachewas ever saved, becauseselect_tests/changed_symbols_from_diffonly auto-save a cache they loaded themselves, andtest_cmd.pyalways supplies its own pre-loaded shared instance (to avoid double-loading) without ever calling.save()on it. Every realmuse code testinvocation was silently re-parsing every touched file's symbol tree from scratch, with only the call-graph half of the cache ever actually warm between runs. Fixed with ashared_sym_cache.save()call at both exit points inrun(). Covered bytests/test_cmd_test.py::TestSymbolCachePersistedAfterRealRun(2 tests), confirmed via genuine RED (reverted the fix, 3/6 related tests failed) → GREEN.Phase 5 — Documentation
docs/code-test.md— new user-facing CLI reference (flags, caching model,--cache-statusschema).docs/code-test-internals.md— the Phase 1 audit, gap map, and the schema-departure Decision.select_tests's docstring now cross-references both.Verification
caplog-based assertion leaked state across twoselect_testscalls in one test; fixed withcaplog.clear()).test_cache_invalidation_gaps.py,test_cache_status.py,test_cache_status_fields.py,test_callgraph.py,test_callgraph_cache.py,test_cmd_clones.py,test_cmd_plan_merge.py,test_cmd_shard.py,test_cmd_test.py,test_core_symbol_cache.py,test_core_test_selection.py,test_query_stat_cache.py,test_test_selection_speedup.py) — all passing.muse-devagainst a disposable/tmprepo: cold run → warning +cache_hit: false; warm run →cache_hit: true, no warning;--cache-statuscorrectly flips fromstale: true/files_cold: 2tostale: false/files_warm: 2after the warm run, with nonzero cache-age values confirming it reads real on-disk state.Acceptance criteria — all met
--jsonalways includescache_hit.docs/code-test.mdcovers all behavior described above.Out of scope (respected, not touched)
Cache storage format/invalidation strategy redesign, distributed/shared caches, Windows filesystem compatibility.