Skip to content

feat: index freshness, staleness & watch robustness - #91

Merged
zzet merged 15 commits into
mainfrom
feat/index-freshness-provenance
Jun 14, 2026
Merged

feat: index freshness, staleness & watch robustness#91
zzet merged 15 commits into
mainfrom
feat/index-freshness-provenance

Conversation

@zzet

@zzet zzet commented Jun 14, 2026

Copy link
Copy Markdown
Owner

A self-describing freshness/provenance layer for the graph: every file knows how it was extracted and whether the graph still matches the working tree; the index records what it could not see. 15 commits, feature-by-feature, each built + go vet + golangci-lint + -race clean.

What's in it

Freshness provenance (the spine)

  • A per-language extractor-version salt mixed into the Merkle leaf — an extractor-logic bump re-extracts only the affected language's files even when content is unchanged; an empty salt reproduces the prior leaf exactly, so it's dormant (zero cost) until a version is bumped.
  • A per-repo repo_index_state row written at index end: the git SHA + dirty flag the graph reflects, the Merkle workspace fingerprint, node/edge counts, and the per-language extractor versions. DB and WAL on-disk sizes now surface in daemon_health.

Skip-ledger (honest accounting for indexing)

  • Parse failures are captured as skip_reason file nodes instead of vanishing — in both the in-process and subprocess extraction paths, full-index only so a transient mid-edit parse failure never zeroes a file's prior symbols (a regression the full suite caught and is fixed here).
  • index_health rolls skips up by reason (size/timeout/minified/parse_failed/parse_panic) and reports a nodes_per_file density plausibility check. .gitignore parsing hardened against non-UTF-8 / oversized lines.

Inline freshness rider

  • File-read tools (read_file/get_symbol_source/get_editing_context/get_file_summary/get_symbol) attach a small freshness block (stale / indexed-sha / dirty-at-index / worktree-mismatch), omitted entirely when fresh (zero extra tokens). O(1) per-target-file staleness that never false-positives on an untracked path.

Reconcile triggers & watch robustness

  • A fast (250ms) notify-file loop in the poller: touch .gortex/reindex.notify (or $GORTEX_NOTIFY_FILE) to force a sub-second reconcile; a post-checkout git hook uses it.
  • WSL2 / 9p / SMB slow-mount detection: disable native fsnotify (where inotify is unreliable), rely on the poller; GORTEX_FORCE_FSNOTIFY=1 overrides.
  • Opt-in (GORTEX_AUTOINDEX=1), bounded, fully-backgrounded zero-config auto-index of an untracked cwd.

Progress, config & incremental soundness

  • Typed index sub-phases + throughput/ETA + a human duration formatter in the readiness payload.
  • A index.include force-include config to index a .gitignore-excluded tree.
  • A doc-only reconcile now skips the capability/dispatch synthesizers (the cross-file inference passes were already scoped to the changed-affected type set).

Deliberately deferred (PART, not in this PR)

Simultaneous multi-branch indexing and worktree-overlay indexing — the overlay-per-branch architecture. The substrate exists (branch-keyed snapshot cache, the overlay manager, worktree detection) but a sound implementation is a dedicated design cycle; a hasty version would risk silent graph divergence, the exact failure this work prevents.

Validation

go build ./... + go vet + golangci-lint green. Each feature -race-tested; the full touched-package -race suite is the final gate.

zzet added 15 commits June 14, 2026 21:07
Mix a per-language extractor version into each Merkle leaf so a file whose
content is unchanged but whose extractor logic was upgraded re-extracts on
the next reconcile — surgically, by language, without re-reading content.
An empty salt reproduces the content-only leaf exactly, so the registry is
dormant (zero behaviour change) until a language version is deliberately
bumped.
Persist a repo_index_state row at the end of every (re)index: the git SHA
+ dirty flag the graph reflects, the Merkle workspace fingerprint, node/
edge counts for plausibility baselining, and the per-language extractor
versions that produced the graph. Optional capability — backends without
durable state skip the write, like the file-mtime ledger.
Report db_bytes / wal_bytes / wal_db_ratio in the daemon_health snapshot so
a runaway WAL high-water mark is observable instead of silently filling the
disk. Optional capability via a DBStatReporter type-assertion — in-memory
backends contribute nothing. (The global-pass short-circuit on an unchanged
workspace already exists via the warmup changedRepos / ReconcileAll guards;
the workspace fingerprint digest is now persisted in repo_index_state.)
…eason

Previously a plain extraction error (not a panic, timeout, or size cap) was
dropped silently — the file vanished from the graph with no trace. Capture
it as a synthetic KindFile skip node, in both the in-process and subprocess
extraction paths, and stamp a uniform skip_reason (size / timeout / minified
/ parse_failed / parse_panic) on every skip-node shape so the reasons roll
up. The Merkle reconcile already retries a failed file when its content or
extractor version changes, so no separate retry ledger is needed.
index_health now reports a skipped:{size,timeout,minified,parse_failed,
parse_panic} breakdown so an agent can see WHY a symbol is missing instead
of guessing, plus a nodes_per_file density with a soft warning when a
populated graph has almost no symbols beyond file shells (a broken grammar
or aborted reindex) — complementing the existing zero-edge sanity check.
…ed lines

A corrupt or DLP-encrypted .gitignore previously fed garbage lines straight
to the exclude matcher and could silently truncate the pattern list on the
default 64 KiB scanner limit. Skip non-UTF-8 lines, raise the scanner buffer,
and keep the patterns already read on any error — exclusion loading is
best-effort and must never abort or mis-scan.
The inline parse-failed skip node broke the load-bearing invariant that a
transient mid-edit parse failure must keep a file's prior symbols (the live
watcher path must not zero a file's nodes). Mirror the size-skip mechanism
instead: the live/re-extract paths keep prior nodes on a parse failure
(unchanged), and the full-index path collects failed files and emits skip
nodes in a post-pass — visible telemetry without the destructive replace.
When a file-reading tool (read_file / get_symbol_source / get_editing_context
/ get_file_summary / get_symbol) returns content for a file that changed on
disk since it was indexed, attach a small structured freshness block so the
agent sees, inline, that the graph view may lag the working tree — with the
indexed SHA and the dirty-at-index flag from repo_index_state. Omitted (zero
extra tokens) for the common fresh case and in multi-repo mode; the check is
O(1) via a new IsTrackedStale that never false-positives on untracked paths.
Add typed Phase constants (discover/parse/resolve/link/persist), a human
duration formatter (45s / 5m 12s / 1h 20m), and an ETA/throughput computation
(items/sec + estimated remaining), surfaced on the warmup parse phase in the
workspace-readiness payload so index progress is legible and estimable.
Add a fast (250ms) notify-file loop to the adaptive poller: touching
<root>/.gortex/reindex.notify (or $GORTEX_NOTIFY_FILE) forces an immediate
HEAD+filesystem reconcile, giving an agent or hook sub-second re-index latency
instead of waiting out the adaptive interval. Register a post-checkout git
hook that touches the notify file, so a branch switch reconciles the working
tree at once. Poller teardown moved to a WaitGroup to join both loops.
…clude

Add a dedicated `include` list to .gortex.yaml: each entry is appended last as
a gitignore !pattern re-include, so it wins over the builtin / .gitignore /
global / repo exclude layers. The readable counterpart to hand-writing
negations, for a vendored or generated tree (Pods/, a per-customer dir) you
want in the graph despite .gitignore.
Detect a Windows drive surfaced into WSL2 via 9p/drvfs (or an SMB share) by
probing /proc/version + statfs, and on such a mount skip the native fsnotify
backend — where inotify events arrive late or never and confirmWatchActive
would hang ~5s per path — relying on the adaptive poller + git hooks instead.
GORTEX_FORCE_FSNOTIFY=1 overrides. No-op on non-Linux and normal mounts.
When GORTEX_AUTOINDEX=1, the first tool call in a session background-indexes
the current working directory if it is an untracked git repo under a tracked
file-count limit. Off by default (auto-indexing is expensive — the user opts
in), bounded (an oversized tree is left for an explicit track), and fully
backgrounded so the request path pays only a getenv + a sync.Once.
…onciles

The implements/override inference passes are already scoped to the changed-
affected type set (runScopedInferencePasses, add-parity with the full pass).
The remaining whole-graph passes — capability-edge and framework-dispatch
synthesis — derive only from code structure, so a reconcile that touched
only non-code files (a README, a config) and removed nothing now skips them
entirely. They stay whole-graph on a genuine code change: sound cross-file
scoping needs an affected-set those synthesizers do not expose, and an
unsound scope would silently diverge the graph from a full pass.
When the working directory is a linked git worktree that the indexed graph
does not cover, read-tool responses now carry a worktree_mismatch flag — the
agent is in one checkout while the graph reflects another, so results may not
match the files on disk. Computed once per session and folded into the
existing inline freshness rider (single-repo; multi-repo owns its own
worktree routing).
@zzet
zzet merged commit cd6ad17 into main Jun 14, 2026
10 checks passed
@zzet
zzet deleted the feat/index-freshness-provenance branch June 15, 2026 07:38
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