Skip to content

Release 0.6.0 — freshness a timestamp cannot fake, a search that stays in your project - #35

Merged
CoderDayton merged 6 commits into
mainfrom
release/0.6.0
Sep 2, 2026
Merged

Release 0.6.0 — freshness a timestamp cannot fake, a search that stays in your project#35
CoderDayton merged 6 commits into
mainfrom
release/0.6.0

Conversation

@CoderDayton

Copy link
Copy Markdown
Owner

Two assumptions in this server were wrong in the same way: both trusted a signal that is cheap to read instead of the one that is true.

Behaviour changes to look at first

  • search with no directory now means the current project, not every project in the store.
  • grep with a relative path, or none, is anchored to the client root. An absolute path still reaches anywhere.

The mtime assumption

Four read and write paths accepted a cache entry as current when its recorded mtime was at least the file's, with no hash check — and one of those was the base text for write(append=true), edit and batch_edit. Any rewrite that preserves timestamps satisfies that gate over different bytes: cp -p, rsync -t, tar -x, touch -d, a checkout that restores mtimes. An append then built on superseded text and wrote it back over the real file. That is data loss, and it needed no race to happen.

Freshness is now exact equality — an mtime that moved backwards was rewritten by exactly those tools, so a backwards jump is the strongest staleness signal a stat can give, not evidence of freshness — and every mutation reads and hashes disk before it writes.

The shape of the store

The docstore is one file shared by every project the client has ever opened. In this repo, grep(pattern="^##", path="CHANGELOG.md") returned two other projects' changelogs and never reached this one before the match cap. Both tools are now anchored to the client root, which is the rule grep's path filter already implied.

For search the directory reaches SQL as a path-prefix clause. That placement is the point: LIMIT applies after WHERE, so filtering after the ranking would spend result slots on files the caller cannot use and return a handful of matches, or none, while plenty existed. The prefix is escaped for LIKE, since a directory may contain % or _.

Chunk integrity

get_content joined a file's child rows and returned them with no comparison to the content_hash they claimed. The rewrite producing those rows was three separate store calls; interrupted between insert and re-tag it left a chunk set that looks healthy — one probe found 80 children numbered 0..79 with no gaps or duplicates, totalling 166,000 bytes for a 164,000-byte file, matching neither version. The join is now verified, and the reconcile is one transaction (also one IO-thread hop instead of three).

Also

  • batch_read joins the responses that name their shared directory once as root.
  • Tool docstrings drop the hash mechanics they restated from the server instructions: 9,436 advertised tokens to 8,791, paid on every request whether or not a tool is called.
  • Pre-push hooks now run exactly what CI runs; mypy blocks, and the ruff ignores live in pyproject.toml rather than only in the hook.

No cache-format change and no migration. See the CHANGELOG entry for the full list, including six smaller fixes.

Verification

  • uv run pytest -q → 5144 passed, 6 skipped
  • uv run mypy src/ → no issues in 45 source files
  • uv run ruff check src/ tests/ and ruff format --check → clean
  • uv run bandit -c pyproject.toml -r src/ → 0 issues

Latency was not measured: the machine was too loaded to produce a number worth publishing (the unrelated edit path swung 3.24–11.42 ms across consecutive runs). The atomic reconcile reduces IO-thread hops from three to one, so it is expected neutral-or-faster, but that is reasoning, not a measurement.

Four read and write paths accepted a cache entry as current when its
recorded mtime was at least the file's, with no hash check. Any rewrite
that preserves timestamps satisfies that gate over different bytes, so
`write append`, `edit` and `batch_edit` could build on superseded text
and write it back over the file.

Freshness is now exact equality, via one shared predicate: an mtime that
moved backwards is the strongest staleness signal a stat can give, not
evidence of freshness. Every mutation reads and hashes disk first, and
uses the cached text only when it provably matches, so an incremental
re-store still reuses the chunk manifest.

The base is read as bytes and decoded rather than through read_text,
whose universal-newline translation would convert a CRLF file to LF on
every edit and never match the stored hash.
…action

get_content joined a file's child rows and returned them with no
comparison to the content_hash they claimed, so a missing or duplicated
row produced wrong content under a hash saying otherwise.

The rewrite that produces those rows was three separate store calls.
Interrupted between insert and re-tag it left a chunk set that looks
healthy — one probe found 80 children numbered 0..79 with no gaps or
duplicates, totalling 166,000 bytes for a 164,000-byte file, matching
neither version. reconcile_chunks now does all three in one transaction,
which also costs one IO-thread hop instead of three. The locked helpers
exist because `with conn:` does not nest.

The join is checked against the hash and raises ContentIntegrityError;
the cache catches that alone — not the store's unrelated ValueErrors —
drops the rows and re-reads from disk.

Queries also take a path scope: grep a `within` root, and keyword_search
a SQL path prefix. The prefix belongs in SQL because LIMIT applies after
WHERE, so filtering afterwards spends result slots on files the caller
cannot use. It is escaped for LIKE, since a directory may contain % or _.
…read

The docstore is one file shared by every project the client has ever
opened. grep with a relative pattern and search with no directory both
ranked across all of it: in this repo, grep for "^##" in CHANGELOG.md
returned two other projects' changelogs and never reached this one
before the match cap.

Both are now anchored to the client root by default, the rule grep's
path filter already implied; an absolute path or explicit directory
still reaches anywhere. The root is resolved before use, since cached
paths are stored resolved and a symlinked checkout would otherwise match
nothing, and it is carried across the worker boundary rather than
re-derived there.

batch_read joins the multi-file responses that name their shared
directory once as root and report paths relative to it, debug summary
included, so one response no longer mixes two path conventions.

Tool docstrings drop the hash mechanics they restated from the server
instructions, which are sent once, and keep only their own exceptions:
9,436 advertised tokens to 8,791, paid on every request.
mypy was advisory on pre-push, and ruff ran with an --ignore list that
existed only in the hook, so the configuration the hook enforced was not
the one in pyproject.toml. A hook that passes what CI will fail is worse
than no hook. The ignores moved into pyproject.toml, mypy blocks, and the
pre-commit/pre-push split is by cost, not by strictness.

The token-savings floors were up to 19 points below the published claim,
so they defended nothing; raised to the figures the README states.
The root is resolved now, because cached document paths are stored
resolved and the prefix test would otherwise miss every one of them.
The test still asserted the literal URI path, which passes on Linux and
fails on macOS, where /home is a firmlink to /System/Volumes/Data/home.
@CoderDayton CoderDayton self-assigned this Sep 2, 2026
@CoderDayton
CoderDayton merged commit 5064205 into main Sep 2, 2026
8 checks passed
@CoderDayton
CoderDayton deleted the release/0.6.0 branch September 2, 2026 08:27
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