gabriel / muse public
Closed #49 Enhancement
filed by gabriel human · 117 days ago

muse code test — implementation audit, cache behavior, and documentation

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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 output
  • muse/core/test_selection.py — symbol-graph-driven test selector; uses CallGraphCache and SymbolCache
  • muse/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 test emits a visible log line when it enters the cold-rebuild path, including what triggered the invalidation.
  • A muse code test --cache-status flag (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 to docs/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 paths
  • CT_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 equivalent
  • CT_04 — --json output includes "cache_hit": true/false at the top level so callers can detect which path was taken
  • CT_05 — Test: mock a cache miss, invoke muse code test --json, assert cache_hit == false and 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 next muse code test
  • CT_07 — Test: unchanged snapshot → cache is reused (no re-parse); verified by counting object-store reads
  • CT_08 — Test: new import added to a source file → call graph is rebuilt; newly reachable test is included in selection
  • CT_09 — Test: test file added with no production changes → new test is included without full cache bust
  • CT_10 — Test: muse code index rebuild followed by muse code test uses 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 --json returns the schema above; exit 0 whether warm or cold
  • CT_12 — stale: true is returned when cache snapshot ID ≠ HEAD snapshot ID
  • CT_13 — stale: false is returned when they match
  • CT_14 — Test: invoke --cache-status on a fresh repo (no cache file) → graceful JSON response with stale: 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.md committed; covers: how test selection works, cache lifecycle, invalidation triggers, warm vs cold performance expectations, --cache-status usage, and how to force a warm-up with muse code index rebuild
  • CT_16 — Inline docstring in muse/core/test_selection.py::select_tests updated to cross-reference docs/code-test.md and document the cold path

Acceptance Criteria

  • muse code test never appears to hang silently; cold rebuilds are always announced
  • --json output always includes "cache_hit"
  • Any cache miss traceable to a specific trigger has a test that proves the trigger works correctly
  • docs/code-test.md exists 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
◎ Activity1
●
gabriel opened this issue 117 days ago
○
gabriel 2 days ago

Resolved — all 5 phases implemented, shipped to main/dev on all remotes

Commit: sha256:ce542c4d0f6dacc9520c02d0e3378bf96bc377d118a7402f2bbc8af1749b08e0 (branch feat/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 SymbolCache and CallGraphCache are 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 in cache.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-status schema (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_snapshot gained an optional stats: dict[str, int] | None out-parameter to observe per-file hit/miss counts without changing their return types (avoided touching ~30 existing call sites across the codebase).
  • SelectionResult gained cache_hit: bool / cache_misses: int.
  • Cold-path emits ⚠️ code-test: cache cold — rebuilding symbol/call-graph data (N file(s) re-parsed).
  • --json (including --dry-run --json) now surfaces cache_hit in the selection object.
  • Test: 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)

  • CT_06 new commit invalidates only the changed file's entry
  • CT_07 unchanged snapshot: zero read_object/parse_symbols calls (mock-spy proof)
  • CT_08 new import → call-graph rebuild → newly-reachable test still selected
  • CT_09 new test file added doesn't bust cache for unrelated already-cached files (verified by inspecting mock call args' object IDs)
  • CT_10 muse code index rebuild real 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 reading index_rebuild.py)

Phase 4 — muse code test --cache-status [--json]

New get_cache_status() in muse/core/test_selection.py — pure dict-lookup + os.stat(), never calls read_object/parse_symbols. Graceful on a fresh repo with no commits and no cache files (verified both via unit tests and live muse-dev runs). 5 tests in tests/test_cache_status.py (CT_11–CT_14) plus 4 CLI-level tests in tests/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 persisted SymbolCache to disk on a real run — only CallGraphCache was ever saved, because select_tests/changed_symbols_from_diff only auto-save a cache they loaded themselves, and test_cmd.py always supplies its own pre-loaded shared instance (to avoid double-loading) without ever calling .save() on it. Every real muse code test invocation 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 a shared_sym_cache.save() call at both exit points in run(). Covered by tests/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-status schema).
  • 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

  • All new/changed test files confirmed via genuine RED (reverted the relevant fix, re-ran, observed real failures) → GREEN, per this workspace's TDD discipline — including catching my own test-design mistake along the way (an initial caplog-based assertion leaked state across two select_tests calls in one test; fixed with caplog.clear()).
  • 424 tests across every direct caller of the touched functions (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.
  • Live-verified via muse-dev against a disposable /tmp repo: cold run → warning + cache_hit: false; warm run → cache_hit: true, no warning; --cache-status correctly flips from stale: true/files_cold: 2 to stale: false/files_warm: 2 after the warm run, with nonzero cache-age values confirming it reads real on-disk state.

Acceptance criteria — all met

  • ✅ Command never appears to hang silently — cold-path warning now always fires.
  • ✅ --json always includes cache_hit.
  • ✅ Every cache-miss trigger identified in the audit has a dedicated test.
  • ✅ docs/code-test.md covers all behavior described above.

Out of scope (respected, not touched)

Cache storage format/invalidation strategy redesign, distributed/shared caches, Windows filesystem compatibility.

✓
closed this issue 2 days ago