NxtSoftLabs
← All writing

Stale code index: three bugs that swallowed the failure

February 2, 2027·9 min read

A code index rarely goes stale because it is slow. It goes stale when its own bookkeeping records a piece of work as done, or as about to happen, when it never will. Three CGraph fixes merged on 2026-10-17 each remove one such marker: a source hash that marked a crashed file as extracted, a debounce timer that never let a busy file's event out, and a cache reader that turned a damaged file into an empty one the next write would replace. None of the three raised an error. This post covers each bug, the fix, and the test that fails on the old behavior.

Where these sit

CGraph's daemon keeps a code graph warm and folds edits into it as files change. The watch loop and the fold-in are described in fold-in vs full rescan, and how an answer proves which source tree it came from is in freshness proofs with a merkle content root. This post is narrower. It is about three places where the daemon's own record of what it had done was wrong, so it had no reason to look again.

All three fixes passed the same ten CI checks, on Linux and macOS runners, and shipped in bin-v0.9.5.

1. A hash recorded before the work was done

Incremental update avoids re-parsing files whose content has not changed. It compares the hash it stored for a file with the hash of what is on disk now, and skips the file when they match. From src/engine/incremental_update.cpp:

if (const auto existing = index.files.find(key);
    existing != index.files.end() && cache_classification.current.has_value() &&
    !existing->second.source_sha256.empty() &&
    existing->second.source_sha256 == cache_classification.current->sha256) {
  ++result.files_cache_hit;
  continue;
}

So a stored hash means "this content was already extracted." The problem, fixed in PR #194 (CGR-18), was in where extract_detected_file set it. It hashed the source first, then ran the extractor:

empty.source_sha256 = sha256_hex(source);
auto result = extract_configured_language(

If the extractor threw, the catch blocks returned that same empty result, hash included. The PR states the consequence: "one extractor crash left the file indexed with zero nodes and it was never retried until its content changed." The failure added a warning to that one pass. After that, every pass over the file was a cache hit on an empty fragment.

The fix records the hash only when extraction completes. A failed extraction now returns a warning, no nodes and an empty hash, and the comment left in its place says why:

// A failed extraction leaves source_sha256 empty. Incremental update treats a
// recorded hash as "this content was already extracted", so a hash here would
// turn one crash into a permanent zero-node entry that is never retried.

One case keeps its hash on purpose. When no extractor is registered for the language, there is nothing to retry, and the code calls the empty fragment "the settled result for this content." The line between a failure and a settled result is the whole fix.

To make an extractor throw on demand, the PR added an overload of extract_detected_file that takes the per-language extractor. The test in tests/smoke/file_extraction_test.cpp passes one that throws on its first call and succeeds after that, then runs the same unchanged file twice:

const auto crashed = cgraph::extract_detected_file(flaky, root, throws_once);
if (!crashed.fragment.nodes.empty() || !crashed.source_sha256.empty() ||
    crashed.fragment.warnings.size() != 1 ||
    crashed.fragment.warnings.front().find("extractor crashed") == std::string::npos) {
  std::filesystem::remove_all(root);
  return 8;
}

const auto retried = cgraph::extract_detected_file(flaky, root, throws_once);
if (retried.fragment.nodes.empty() || !retried.fragment.warnings.empty() ||
    retried.source_sha256 != cgraph::sha256_hex(source)) {
  std::filesystem::remove_all(root);
  return 9;
}

The PR says this test fails with the old behavior. A second test in incremental_update_test.cpp covers the other half: it puts what a crashed extraction leaves into the index, touches the file without changing it, and requires files_reextracted == 1 and files_cache_hit == 0.

2. A debounce that restarted on every change

The file watcher polls the tree and debounces each path, so a burst of writes to one file becomes one event. Our fold-in post described it as coalescing a save burst "into a single event after 250ms of quiet." The words "of quiet" were the bug.

FileWatcher::poll flushes a pending event once the debounce has passed since first_seen:

if (now - iterator->second.first_seen >= options_.debounce) {

Before PR #206 (CGR-19), every change to a path replaced its pending event with a new one, and with it a new start time:

pending_[key] = PendingEvent{
    .event = FileWatchEvent{.path = std::filesystem::path(key), .change = FileWatchChange::Modified, .kind = state.kind},
    .first_seen = now,
};

A file that changed on every poll restarted its window on every poll, and its event never fired. The PR names the files that do this: "a log, a generated file, one an agent is still writing." The graph kept the version from before the writing started, for as long as the writing went on.

The fix sets first_seen only when no event is pending for the path. A later change replaces the event's payload and leaves the deadline alone:

const auto [pending, inserted] = pending_.try_emplace(key, PendingEvent{.event = event, .first_seen = now});
if (!inserted) {
  pending->second.event = std::move(event);
}

The daemon already had this rule in one place. mark_graph_dirty in daemon_lifecycle.cpp decides when to persist the graph, and its comment reads: "re-marking on every subsequent edit must not push the persist deadline out, or a steady edit stream would starve persistence indefinitely." The watcher now follows it too, for Created and Deleted events as well as Modified. The ticket only named Modified.

The new test in tests/smoke/file_watcher_test.cpp grows a file on every 20 ms poll with a 50 ms debounce, for eight polls, and records when the Modified event comes out:

// First flush at +80ms (change at +20ms, 60ms elapsed >= 50ms; at +60ms only
// 40ms had). The next change starts a fresh window at +100ms, flushed at +160ms.
if (emitted_at.size() != 2 || emitted_at[0] != 80ms || emitted_at[1] != 160ms) {
  return 3;
}

Under the old code each poll reset the start time, 20 ms is less than 50 ms, and nothing was emitted. The PR confirms the test fails with the old behavior. It also changed an existing case that had asserted the old behavior: two writes 30 ms apart used to flush 50 ms after the second, and now flush 50 ms after the first.

The fix has a cost, and the PR states it: "a file that is still being written is now reported once per debounce window instead of not at all, so a long write can produce an event mid-write." The PR's answer is that no change is lost, because the event carries the latest state and the next change opens a new window. So a long write can now fold a half-written file into the graph, and the next window brings in the rest. Before, the graph went without that file's changes until the writing stopped.

3. A damaged cache read as an empty one

CGraph can attach LLM-written descriptions to the graph through its enrichment pipeline, and the semantic cache records which fragments are done. PR #207 (CGR-20) opens with: "The semantic cache holds LLM-authored enrichment, but it had the weakest persistence in cgraph."

Writes were already atomic, a temp file renamed into place. Reads were the problem. A truncated file, one malformed record, a duplicate source or an old schema version read as an empty cache, and the PR describes what followed: "the next write then replaced the original." An index that read "unusable" as "nothing here yet" then replaced the file that held the enrichment.

The reader still returns an empty cache for such a file. The PR keeps that because existing tests rely on it. What changed is the write. write_semantic_cache now checks the existing file first, and if it is unusable, moves it aside before writing. From src/engine/semantic_cache.cpp:

// An existing cache file that read_semantic_cache cannot use is moved to
// `<name>.invalid` (`.invalid.1`, ... when taken) before a write replaces it.
// The reader returns an empty cache for such a file, so without this the next
// write would overwrite the only copy of LLM-authored enrichment. Throws
// FileWriteError when the file cannot be moved: not overwriting is the point.

The reader also takes an optional problem string that says why a file was unusable, such as "it is not valid JSON" or "a record is malformed." graphd logs that it ignored the cache and where the file went, and cgraph enrich ingest prints a warning: line. The PR notes two side effects: a version 1 cache is now kept as .invalid on the first write instead of being replaced, and each write re-reads the existing file to check that it is usable.

The test in tests/smoke/semantic_cache_test.cpp writes a cache with one bad record, writes over it, and checks that the original bytes survive under the new name:

const auto first = cgraph::write_semantic_cache(make_cache(2), path);
expect(ok, first.has_value() && *first == std::filesystem::path(path.string() + ".invalid"),
       "bad record: the write reports where the old file went");
expect(ok, first.has_value() && read_all(*first) == damaged, "bad record: the original bytes are kept");
expect(ok, cgraph::read_semantic_cache(path).size() == 2, "bad record: the new cache is written in place");

A second unusable file gets .invalid.1 and leaves the first copy intact. Duplicate, old-version and not-JSON files are set aside the same way. Missing and usable files are not moved. The PR reports that the new assertions fail without the set-aside, with 7 failures.

The common shape

Each bug had a marker that stood for a fact: "this content was extracted," "this event is due soon," "this cache is empty." Each marker could be set when the fact was false, and nothing checked it again later:

FixMarkerSet whenResult
#194source_sha256before the extractor rana crashed file stays at zero nodes until its content changes
#206first_seenon every change, not the firsta file that keeps changing is never reported
#207an empty cachewhen the file was unusable, not absentthe next write replaces the original

All three fixes do the same thing: set the marker only when the fact holds. Hash after extraction, start the clock at the first change, and keep "absent" and "unusable" apart. None of them needed a retry loop, a timeout or a background check.

If you maintain an index of your own, whether code, documents or embeddings, look for the places that record "done" or "nothing to do." For each one, ask what happens if the step before it fails, or never finishes. If the answer is that the marker gets set anyway, the index will look healthy and stay stale.

Try it

The three fixes are in bin-v0.9.5 and later. If the graph stops reflecting a file you know has changed, open an issue on CGraph with the daemon's log.