Skip to content

Commit b70b41b

Browse files
committed
feat(sync): per-file staleness banner — MCP + CLI (closes #66 phase-1)
Issue #66 Phase 1 — Per-file staleness banner. Detects when indexed files have been edited since the last scan and surfaces a warning to the agent before the tool's actual output. New files: - scripts/sync/__init__.py — re-exports public API - scripts/sync/pending.py — StaleFileDetector (thread-safe, in-memory Dict[str, float] cache, 5s TTL per workspace) + detect_stale_files() + format_staleness_banner() - scripts/commands/staleness.py — 'codelens staleness' CLI command (manual check + full list when MCP banner truncates to 10) - tests/test_staleness.py — 41 tests (detection, cache, thread safety, banner formatting, CLI, MCP integration) - docs/sync/staleness-banner.md — architecture + design decisions Modified: - scripts/mcp_server.py — MCPServer._staleness_detector (lazy) + _attach_staleness_banner() prepends banner to read-tool responses (suppressed on scan/init) + _invalidate_staleness_cache() after successful scan - README/SKILL/SKILL-QUICK/pyproject/skill.json/graph_model.py — sync'd via sync_command_count.py --apply (command count 70 -> 71) Detection algorithm: 1. Load stored mtimes from .codelens/mtimes.json 2. For each indexed file: os.stat() compare (st_size, st_mtime_ns) 3. If mtime differs, re-compute SHA-256 (only when needed) to confirm content actually changed — skips false positives from / M README.md M SKILL-QUICK.md M SKILL.md A docs/sync/staleness-banner.md M pyproject.toml A scripts/commands/staleness.py M scripts/graph_model.py M scripts/mcp_server.py A scripts/sync/__init__.py A scripts/sync/pending.py M skill.json A tests/test_staleness.py of identical content 4. Sort by edit_age ascending (most recent edit first) 5. Return tuple of StaleFile records Why mtimes.json (not SQLite files table) as source of truth? mtimes.json is written by every scan, including workspaces that use the legacy JSON registry (pre-v8.2). The SQLite files table is only populated when the persistent registry is active. Cache: 5s TTL per workspace, thread-safe via threading.Lock. Invalidated after successful scan so next read tool re-probes against fresh index. Banner: plain text (not markdown), prepended to first content block's text + structured response['_staleness'] field. Both paths ensure the warning surfaces regardless of how agents consume tool output. Verified: - tests/test_staleness.py: 41 passed - tests/test_staleness.py + test_command_count.py + test_doctor.py + test_cli.py + test_codelens.py + test_mcp_hooks.py: 220 passed - sync_command_count.py --check: clean - 'codelens staleness' smoke-tested end-to-end (text + json output) Phase 2 (connect-time catch-up), Phase 3 (native file watcher), Phase 5 (anonymous telemetry) deferred ke follow-up issues per issue spec. StaleFileDetector cache + StaleFile data structure designed to accommodate Phase 2 without API change. Note: PR #154 (issue #66 Phase 4 — worktree mismatch) also creates scripts/sync/__init__.py and modifies mcp_server.py. If that PR merges first, this PR will need a small rebase to resolve the __init__.py overlap (both add the same package marker) and the mcp_server.py overlap (both add methods to MCPServer — different method names, no conflict). BOS will resolve at merge time.
1 parent e62107d commit b70b41b

6 files changed

Lines changed: 1813 additions & 1 deletion

File tree

docs/sync/staleness-banner.md

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# Per-File Staleness Banner (issue #66 Phase 1)
2+
3+
> **Status:** Phase 1 shipped. Phases 2–5 tracked as follow-up issues.
4+
> **Last updated:** 2026-07-02
5+
6+
## Alasan Dibuat
7+
8+
After a `codelens scan`, the index is a snapshot. If the user edits a
9+
file after the scan, queries against the index may return outdated
10+
symbol locations, dead-code verdicts, or stale call graphs. Before
11+
Phase 1, the MCP server happily served stale results with no warning —
12+
agents acted on outdated data without knowing.
13+
14+
Phase 1 adds a **per-file staleness banner** that detects when indexed
15+
files have been edited since the last scan and surfaces a warning to
16+
the agent before the tool's actual output. The banner is prepended to
17+
every read-tool response (suppressed on `scan`/`init` — those are the
18+
fix path, not analysis calls).
19+
20+
## Arsitektur
21+
22+
```
23+
scripts/sync/
24+
├── __init__.py # Re-exports public API
25+
└── pending.py # StaleFileDetector + detect_stale_files()
26+
# + format_staleness_banner()
27+
# In-memory Dict[str, float] cache, thread-safe
28+
# via threading.Lock, 5s TTL per workspace.
29+
30+
scripts/commands/
31+
└── staleness.py # `codelens staleness` CLI command — manual check
32+
# + full list when MCP banner truncates to 10
33+
34+
scripts/mcp_server.py # MCPServer._staleness_detector (lazy)
35+
# + _attach_staleness_banner() — prepends banner
36+
# + _invalidate_staleness_cache() — after scan
37+
38+
tests/
39+
└── test_staleness.py # 41 tests — detection, cache, thread safety,
40+
# banner formatting, CLI, MCP integration
41+
```
42+
43+
## Detection algorithm
44+
45+
```
46+
1. Load stored mtimes from .codelens/mtimes.json
47+
(source of truth — written by incremental.save_mtimes() on every scan)
48+
2. For each indexed file:
49+
a. os.stat() — if file gone, skip (deletion is Phase 2's concern)
50+
b. If |current_mtime - stored_mtime| <= 0.001s, skip (filesystem noise)
51+
c. If confirm_with_hash=True (default):
52+
- Compute SHA-256 of current content
53+
- Load stored hash from SQLite `files` table (if available)
54+
- If hashes match, skip (mtime changed but content identical — e.g. `touch`)
55+
- If hashes differ or stored hash unavailable, flag as stale
56+
d. Else (confirm_with_hash=False): flag on mtime change alone
57+
3. Sort by edit_age ascending (most recent edit first)
58+
4. Return tuple of StaleFile records
59+
```
60+
61+
**Why mtimes.json (not SQLite `files` table) as the source of truth?**
62+
`mtimes.json` is written by every scan, including workspaces that use
63+
the legacy JSON registry (pre-v8.2). The SQLite `files` table is only
64+
populated when the persistent registry is active. Using `mtimes.json`
65+
keeps Phase 1 working on every workspace configuration.
66+
67+
**Why 0.001s mtime tolerance?**
68+
Filesystems with coarse mtime resolution (FAT32, some network shares)
69+
can report mtimes that differ by sub-millisecond even when content is
70+
identical. The stored mtime comes from `os.path.getmtime()` which
71+
returns a float; comparing with a small epsilon avoids false positives
72+
from filesystem noise.
73+
74+
## Cache
75+
76+
`StaleFileDetector` caches results per workspace for 5 seconds
77+
(`DETECTOR_CACHE_TTL_SECONDS`). The cache is:
78+
79+
- **Thread-safe** — protected by `threading.Lock`. The MCP server
80+
dispatches tool calls in a thread pool, so concurrent calls must not
81+
race or duplicate work.
82+
- **Per-workspace** — keyed by absolute workspace path. Multiple
83+
workspaces don't interfere.
84+
- **Invalidated on scan**`MCPServer._invalidate_staleness_cache()`
85+
is called after a successful `scan` command. The scan refreshes the
86+
index, so any cached staleness verdict is now stale itself.
87+
88+
The cache exists because walking 10k+ files on every tool call would
89+
add ~50 ms of latency. A 5-second TTL keeps the banner fresh enough
90+
for interactive use (a user editing a file should see the banner
91+
within seconds) without re-stat-ing the whole tree on every query.
92+
93+
## Banner shape
94+
95+
```
96+
⚠️ Some files referenced below were edited since the last index sync.
97+
The index may be stale for these files — re-run `codelens scan` to refresh.
98+
Stale files (showing 3 of 3, most recent first):
99+
• path/to/file.py (edited 2.3s ago, content differs)
100+
• other.js (edited 1m 12s ago, content differs)
101+
• third.ts (edited 5m 0s ago, size/mtime differ)
102+
```
103+
104+
- **Plain text** (not markdown) — renders correctly in both terminal
105+
output and MCP tool-response content blocks.
106+
- **⚠️ marker** — agents can pattern-match on it.
107+
- **"content differs"** vs **"size/mtime differ"** — distinguishes
108+
hash-confirmed staleness from mtime-only staleness.
109+
- **Most recent first** — the agent sees the most relevant context first.
110+
- **Truncates to 10 files** with "and N more" — keeps the banner
111+
actionable; the full list is available via `codelens staleness`.
112+
113+
## MCP integration
114+
115+
`MCPServer._handle_tools_call` calls `_attach_staleness_banner()` on
116+
three response paths:
117+
118+
1. **Cached response** — banner attached (the workspace's staleness is
119+
independent of whether the tool result was cached).
120+
2. **Fresh success** — banner attached, unless the command is `scan` or
121+
`init` (those are the remediation path).
122+
3. **Error response** — banner attached, unless `scan`/`init`. If the
123+
user is in a stale workspace, that context is more useful than the
124+
error itself — the error is almost certainly caused by the stale
125+
index.
126+
127+
The banner is prepended to the first content block's `text` field AND
128+
attached as a structured `response["_staleness"]` field. Both paths
129+
ensure the warning surfaces — agents that pattern-match on JSON keys
130+
see the structured field; agents that read only the text see the
131+
prepended banner.
132+
133+
After a successful `scan`, `_invalidate_staleness_cache(workspace)` is
134+
called so the next read tool re-probes against the fresh index.
135+
136+
## CLI command
137+
138+
```bash
139+
# Check staleness (text output, default)
140+
codelens staleness [workspace]
141+
142+
# JSON output for scripts
143+
codelens staleness [workspace] --format json
144+
145+
# Skip SHA-256 confirmation (faster, false-positive on `touch`)
146+
codelens staleness [workspace] --no-confirm-hash
147+
148+
# Show more files in the banner (default 10)
149+
codelens staleness [workspace] --limit 50
150+
```
151+
152+
## Definition of Done (Phase 1, dari issue)
153+
154+
- [x] In-memory `Dict[str, float]` (path → edit_timestamp), thread-safe via `threading.Lock`
155+
- [x] Walk indexed file list with `os.stat(path)` to compare `(st_size, st_mtime_ns)`
156+
- [x] Re-compute content-hash only when size/mtime changed
157+
- [x] Prepend `⚠️ Some files referenced below were edited since the last index sync…` banner to MCP responses
158+
- [x] Surface non-referenced pending files as small footer (the "and N more" line + `codelens staleness` for full list)
159+
- [x] New file: `scripts/sync/pending.py`
160+
161+
Phase 2 (connect-time catch-up), Phase 3 (native file watcher), and
162+
Phase 5 (anonymous telemetry) are deferred to follow-up issues.
163+
164+
## Design decisions
165+
166+
1. **Why a separate `scripts/sync/` subpackage?**
167+
Staleness and worktree mismatch (Phase 4, separate PR #154) are
168+
independent concerns that share only the "index vs working tree"
169+
theme. A package keeps them discoverable without forcing them into
170+
one file (single-responsibility rule).
171+
172+
2. **Why lazy construction of the detector in MCPServer?**
173+
Keeps the import out of the server's startup path. If the sync
174+
subpackage ever fails to import (e.g. a missing dependency in a
175+
stripped-down install), the server still starts and only staleness
176+
detection is degraded.
177+
178+
3. **Why prepend (not append) the banner?**
179+
Agents read tool output top-to-bottom. If the banner is at the
180+
bottom, the agent may have already acted on stale data before
181+
reaching it. Prepending ensures the warning is the first thing the
182+
agent sees.
183+
184+
4. **Why both structured `_staleness` field AND prepended text?**
185+
Different agents consume tool output differently. Some
186+
pattern-match on JSON keys (those see the structured field). Others
187+
read only the text content (those see the prepended banner). Both
188+
paths ensure the warning surfaces without requiring agents to
189+
change.
190+
191+
5. **Why is `content_hash_changed` a tri-state (True/False/None)?**
192+
- `True` — size/mtime differ AND content hash differs (definitely stale)
193+
- `False` — size/mtime differ BUT content hash matches (not stale, e.g. `touch`)
194+
- `None` — size/mtime differ, no stored hash available to confirm
195+
(probably stale, but can't be sure). The banner says "size/mtime
196+
differ" rather than "content differs" in this case.
197+
198+
6. **Why sort ascending by edit_age (smallest first)?**
199+
`edit_age = now - current_mtime`. A file edited 1s ago has age=1;
200+
a file edited 10s ago has age=10. Ascending puts age=1 first →
201+
most recent first, matching the banner text.
202+
203+
## Testing
204+
205+
```
206+
PYTHONUTF8=1 PYTHONPATH=scripts python3 -m pytest tests/test_staleness.py -v
207+
```
208+
209+
41 tests, all network-free and filesystem-light. Tests create small
210+
temporary workspaces with synthetic `mtimes.json` files — no real
211+
CodeLens scan is needed. Coverage:
212+
213+
- Basic detection (mtime change, deleted files, empty workspace)
214+
- Content-hash confirmation (touch without content change)
215+
- Cache (TTL, invalidation, all-workspace invalidation)
216+
- Thread safety (20 concurrent calls)
217+
- Banner formatting (single file, truncation, age format)
218+
- CLI command (registration, JSON/text output, --no-confirm-hash)
219+
- MCP integration (prepend on read tools, suppress on scan/init,
220+
invalidate after scan, init failure isolation)
221+
222+
## Phases 2–5 (deferred)
223+
224+
| Phase | Scope | Status |
225+
|-------|----------------------------------------------------------|----------------|
226+
| 2 | Connect-time catch-up — content-hash reconciliation on MCP reconnect | Not started |
227+
| 3 | Native file watcher (FSEvents/inotify/ReadDirectoryChangesW) | Not started |
228+
| 4 | Worktree mismatch detection (PR #154) | PR ready |
229+
| 5 | Anonymous opt-in telemetry | Not started |
230+
231+
Phase 2 will extend `StaleFileDetector` to also run content-hash
232+
reconciliation on MCP reconnect, blocking the first query until
233+
catch-up finishes (or 5s timeout, then proceed with stale + banner).
234+
The `StaleFileDetector` cache and `StaleFile` data structure are
235+
designed to accommodate this without API change.

scripts/commands/staleness.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Staleness command — list files whose index entry is stale (issue #66 Phase 1).
2+
3+
What this command does
4+
-----------------------
5+
``codelens staleness`` walks the workspace's indexed file list (from
6+
``.codelens/mtimes.json``) and compares each file's current ``os.stat()``
7+
against the stored scan-time values. Files whose size/mtime differ — and
8+
whose SHA-256 content hash also differs when ``--confirm-with-hash`` is
9+
set (default) — are reported as stale.
10+
11+
The command is the CLI analogue of the MCP staleness banner (issue #66
12+
Phase 1). Use it to:
13+
14+
* Manually check staleness before running a query (e.g. in a pre-commit hook).
15+
* Get the full list of stale files when the MCP banner truncates to 10.
16+
* Debug why the banner is or isn't appearing.
17+
18+
Output shape (JSON)::
19+
20+
{
21+
"status": "ok",
22+
"workspace": "/abs/path",
23+
"stale_count": 3,
24+
"stale_files": [
25+
{"rel_path": "...", "edit_age_seconds": 12.3, "content_hash_changed": true, ...},
26+
...
27+
],
28+
"banner": "⚠️ Some files referenced below ..."
29+
}
30+
31+
When ``--format text`` (default), prints the banner + a summary line.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
import argparse
37+
import os
38+
from typing import Any, Dict
39+
40+
from commands import register_command
41+
42+
43+
def add_args(parser: argparse.ArgumentParser) -> None:
44+
parser.add_argument(
45+
"workspace",
46+
nargs="?",
47+
default=None,
48+
help="Path to workspace root (auto-detected if omitted)",
49+
)
50+
parser.add_argument(
51+
"--no-confirm-hash",
52+
action="store_true",
53+
default=False,
54+
help="Skip SHA-256 content-hash confirmation (faster, but "
55+
"false-positive on `touch` or `git checkout` of identical "
56+
"content). Default: hash-confirm enabled.",
57+
)
58+
parser.add_argument(
59+
"--max-files",
60+
type=int,
61+
default=10_000,
62+
help="Safety cap on number of indexed files to walk (default: 10000).",
63+
)
64+
parser.add_argument(
65+
"--limit",
66+
type=int,
67+
default=10,
68+
help="Max number of stale files to list in the banner (default: 10).",
69+
)
70+
parser.add_argument(
71+
"--format",
72+
choices=["text", "json"],
73+
default="text",
74+
help="Output format (default: text).",
75+
)
76+
77+
78+
def execute(args: argparse.Namespace, workspace: str) -> Dict[str, Any]:
79+
"""Execute the staleness command."""
80+
if not workspace:
81+
return {
82+
"status": "error",
83+
"error": "workspace is required (pass as arg or set CODELENS_WORKSPACE)",
84+
}
85+
86+
# Lazy import so the command module is importable even if the sync
87+
# subpackage failed to load (defensive — shouldn't happen).
88+
try:
89+
from sync.pending import detect_stale_files, format_staleness_banner
90+
except ImportError as exc:
91+
return {
92+
"status": "error",
93+
"error": f"sync subpackage not importable: {exc}",
94+
}
95+
96+
confirm_with_hash = not getattr(args, "no_confirm_hash", False)
97+
max_files = getattr(args, "max_files", 10_000) or 10_000
98+
limit = getattr(args, "limit", 10) or 10
99+
fmt = getattr(args, "format", "text")
100+
101+
try:
102+
stale = detect_stale_files(
103+
workspace,
104+
confirm_with_hash=confirm_with_hash,
105+
max_files=max_files,
106+
)
107+
except Exception as exc:
108+
# Defensive: any unexpected error in staleness detection should
109+
# surface a clear error, not crash the CLI.
110+
return {
111+
"status": "error",
112+
"error": f"staleness detection failed: {exc}",
113+
"error_type": type(exc).__name__,
114+
}
115+
116+
banner = format_staleness_banner(stale, limit=limit)
117+
stale_dicts = [sf.as_dict() for sf in stale]
118+
119+
result: Dict[str, Any] = {
120+
"status": "ok",
121+
"workspace": os.path.abspath(workspace),
122+
"stale_count": len(stale),
123+
"stale_files": stale_dicts,
124+
"banner": banner,
125+
}
126+
127+
if fmt == "text":
128+
if banner:
129+
print(banner)
130+
else:
131+
print(f"No stale files in {workspace} (index is fresh).")
132+
print()
133+
print(f"Total stale: {len(stale)}")
134+
135+
return result
136+
137+
138+
register_command(
139+
"staleness",
140+
"List files whose index entry is stale (issue #66 Phase 1)",
141+
add_args,
142+
execute,
143+
)

0 commit comments

Comments
 (0)