Context
Empirically discovered this session: after PR #10258 merged (and both harnesses restarted), a follow-up round-trip revealed the cross-process coherence gap is not fully closed. Peer-written messages addressed directly to me still do not surface in my list_messages cache until a fresh MCP spawn.
Specific case: Gemini sent MESSAGE:d559987d-... from @neo-gemini-pro → @neo-opus-ada at 20:57:09Z. SQLite has it. Her Antigravity harness wrote it cleanly (correct SENT_BY + SENT_TO edges per raw query). But my long-running Claude Code MCP process (uptime ~10min) cannot see it via list_messages even though #10258's vicinity-reload fix IS live in the running process.
Gemini saw my messages correctly on her side because she restarted post-#10258. I restarted too and saw her earlier broadcasts — but her LATER DM (sent while my process was still running) doesn't propagate to my cache.
The Problem
Database.syncCache() at ai/graph/Database.mjs:~78-103 invalidates cached entries based on GraphLog WAL delta. Current logic:
this.nodes.remove(delta.invalidNodes);
delta.invalidNodes.forEach(id => {
this.vicinityLoadedNodes.delete(id);
this.lastAccessMap.delete(id);
});
The gap: when a peer process adds a NEW edge that terminates at an existing node in my cache, syncCache invalidates the new edge from cache but does NOT invalidate the vicinityLoadedNodes entry for the endpoint node. So when #10258's getAdjacentNodes(endpoint, direction) fires, it sees vicinityLoadedNodes.has(endpoint) === true (from an earlier load), skips loadNodeVicinitySync, and the new edge never re-hydrates into cache.
Concretely for my case:
- Gemini writes
MESSAGE:d559987d with SENT_TO → @neo-opus-ada edge
- GraphLog gets 2 new entries (node + edge)
- My process's
syncCache reads delta, invalidates d559987d (new node) + the edge ID
vicinityLoadedNodes has @neo-opus-ada from a prior load (from earlier in this session when I seeded the vicinity)
syncCache does NOT clear @neo-opus-ada from vicinityLoadedNodes (because @neo-opus-ada is not in delta.invalidNodes — it's an existing unchanged node)
- My
list_messages calls getAdjacentNodes('@neo-opus-ada', 'inbound') → skips lazy-reload because vicinityLoadedNodes.has(target) is true
- Iteration over
edges.items misses the new edge
list_messages returns stale cache
The Architectural Reality
ai/graph/Database.mjs — syncCache() implementation + vicinityLoadedNodes management
ai/graph/storage/SQLite.mjs — load(lastSyncId) returns delta with invalidNodes + invalidEdges arrays
- For a new edge
<source, target, type>, the current delta-processing invalidates the edge but leaves both source and target endpoints' vicinity-loaded state untouched
loadNodeVicinitySync(nodeId) is the authoritative re-hydration primitive; it re-loads all edges where nodeId is source OR target
The Fix
Extend syncCache to invalidate vicinityLoadedNodes for BOTH endpoints of every invalidated edge:
delta.invalidEdges.forEach(edgeId => {
const edge = this.edges.get(edgeId);
if (edge) {
this.vicinityLoadedNodes.delete(edge.source);
this.vicinityLoadedNodes.delete(edge.target);
} else {
}
this.edges.remove(edgeId);
});Preferred direction: modify SQLite.load(lastSyncId) to return delta.invalidEdges as [{id, source, target}] objects instead of just IDs, so syncCache has the endpoint info for free. Backward-compat: check the shape, handle both.
Acceptance Criteria
Out of Scope
- Proactive vicinity pre-fetching or subscription-based cache warming — defer until empirical demand
- Rewriting the GraphLog schema for edge-endpoint denormalization — current SQLite trigger records log entity-id only; if we need endpoint info without schema change, fetch from
Edges table at delta-load time
- Extending the fix to ChromaDB metadata coherence — orthogonal substrate
Avoided Traps
- "Always re-run
loadNodeVicinitySync on every getAdjacentNodes call" — rejected. Defeats the point of caching; every read becomes a SQLite roundtrip.
- "Poll
vicinityLoadedNodes TTL-based invalidation" — rejected. Timer-based invalidation introduces latency floor; delta-driven invalidation is strictly better.
- "Fix this at the MailboxService layer by always calling
loadNodeVicinitySync before iteration" — rejected. Wrong layer. The bug is in Database.syncCache's incomplete delta-processing, not in mailbox-specific logic. Fixing at mailbox would leave every other edge-type-scan consumer vulnerable to the same gap.
Related
- #10258 — cross-process coherence for mailbox read paths. This ticket is the deeper substrate fix that closes the remaining gap #10258's MailboxService-layer fix couldn't address alone.
- #10257 — origin ticket for the mailbox-layer coherence fix.
- #10190 / PR #10221 — foundational
syncCache substrate fix. This ticket extends the invalidation semantics.
- #10139 — A2A mailbox primitive. Empirical "A2A works without restart" fully satisfied only once this lands.
Origin Session ID
Origin Session ID: 29f490c0-41ed-4846-a767-287552d5c3c4
Handoff Retrieval Hints
Retrieval Hint: "syncCache vicinityLoadedNodes edge endpoint invalidation gap"
Retrieval Hint: "cross-process mailbox coherence #10258 follow-up"
Commit-range anchor: dev HEAD 96ae5f866 at ticket filing
Context
Empirically discovered this session: after PR #10258 merged (and both harnesses restarted), a follow-up round-trip revealed the cross-process coherence gap is not fully closed. Peer-written messages addressed directly to me still do not surface in my
list_messagescache until a fresh MCP spawn.Specific case: Gemini sent
MESSAGE:d559987d-...from@neo-gemini-pro→@neo-opus-adaat 20:57:09Z. SQLite has it. Her Antigravity harness wrote it cleanly (correctSENT_BY+SENT_TOedges per raw query). But my long-running Claude Code MCP process (uptime ~10min) cannot see it vialist_messageseven though #10258's vicinity-reload fix IS live in the running process.Gemini saw my messages correctly on her side because she restarted post-#10258. I restarted too and saw her earlier broadcasts — but her LATER DM (sent while my process was still running) doesn't propagate to my cache.
The Problem
Database.syncCache()atai/graph/Database.mjs:~78-103invalidates cached entries based onGraphLogWAL delta. Current logic:// Conceptual — actual code iterates delta entries this.nodes.remove(delta.invalidNodes); delta.invalidNodes.forEach(id => { this.vicinityLoadedNodes.delete(id); this.lastAccessMap.delete(id); }); // similar for delta.invalidEdges — removes edges but does NOT invalidate vicinity of endpointsThe gap: when a peer process adds a NEW edge that terminates at an existing node in my cache,
syncCacheinvalidates the new edge from cache but does NOT invalidate thevicinityLoadedNodesentry for the endpoint node. So when#10258'sgetAdjacentNodes(endpoint, direction)fires, it seesvicinityLoadedNodes.has(endpoint) === true(from an earlier load), skipsloadNodeVicinitySync, and the new edge never re-hydrates into cache.Concretely for my case:
MESSAGE:d559987dwithSENT_TO → @neo-opus-adaedgesyncCachereads delta, invalidatesd559987d(new node) + the edge IDvicinityLoadedNodeshas@neo-opus-adafrom a prior load (from earlier in this session when I seeded the vicinity)syncCachedoes NOT clear@neo-opus-adafromvicinityLoadedNodes(because@neo-opus-adais not indelta.invalidNodes— it's an existing unchanged node)list_messagescallsgetAdjacentNodes('@neo-opus-ada', 'inbound')→ skips lazy-reload becausevicinityLoadedNodes.has(target)is trueedges.itemsmisses the new edgelist_messagesreturns stale cacheThe Architectural Reality
ai/graph/Database.mjs—syncCache()implementation +vicinityLoadedNodesmanagementai/graph/storage/SQLite.mjs—load(lastSyncId)returns delta withinvalidNodes+invalidEdgesarrays<source, target, type>, the current delta-processing invalidates the edge but leaves bothsourceandtargetendpoints' vicinity-loaded state untouchedloadNodeVicinitySync(nodeId)is the authoritative re-hydration primitive; it re-loads all edges wherenodeIdis source OR targetThe Fix
Extend
syncCacheto invalidatevicinityLoadedNodesfor BOTH endpoints of every invalidated edge:// At the delta-processing site in Database.syncCache delta.invalidEdges.forEach(edgeId => { const edge = this.edges.get(edgeId); // may already be null if not cached if (edge) { // Invalidate vicinity for both endpoints so the next getAdjacentNodes // call against either one triggers loadNodeVicinitySync, re-hydrating // the new/changed edge from SQLite. this.vicinityLoadedNodes.delete(edge.source); this.vicinityLoadedNodes.delete(edge.target); } else { // Edge not in local cache — we can't know its endpoints without a // SQLite lookup. For new peer-written edges, read the endpoint IDs // directly from storage (the delta load should already carry them). // Alternative: have storage.load() surface edgeEndpoints in the delta // payload so syncCache has the info without an extra SELECT. } this.edges.remove(edgeId); });Preferred direction: modify
SQLite.load(lastSyncId)to returndelta.invalidEdgesas[{id, source, target}]objects instead of just IDs, sosyncCachehas the endpoint info for free. Backward-compat: check the shape, handle both.Acceptance Criteria
Database.syncCache()invalidatesvicinityLoadedNodesfor both endpoints of each invalidated edgeSQLite.load()surfaces endpoint metadata indelta.invalidEdges(object shape, not just ID array)test/playwright/unit/ai/graph/Database.spec.mjs: twoDatabaseinstances against the same file; instance A writes an edge, instance B'ssyncCacheinvalidates its endpoints, nextgetAdjacentNodeson either endpoint surfaces the new edge without explicit vicinity-clearlist_messagessurfaces the DM without harness restartOut of Scope
Edgestable at delta-load timeAvoided Traps
loadNodeVicinitySyncon everygetAdjacentNodescall" — rejected. Defeats the point of caching; every read becomes a SQLite roundtrip.vicinityLoadedNodesTTL-based invalidation" — rejected. Timer-based invalidation introduces latency floor; delta-driven invalidation is strictly better.loadNodeVicinitySyncbefore iteration" — rejected. Wrong layer. The bug is inDatabase.syncCache's incomplete delta-processing, not in mailbox-specific logic. Fixing at mailbox would leave every other edge-type-scan consumer vulnerable to the same gap.Related
syncCachesubstrate fix. This ticket extends the invalidation semantics.Origin Session ID
Origin Session ID:
29f490c0-41ed-4846-a767-287552d5c3c4Handoff Retrieval Hints
Retrieval Hint:
"syncCache vicinityLoadedNodes edge endpoint invalidation gap"Retrieval Hint:"cross-process mailbox coherence #10258 follow-up"Commit-range anchor: dev HEAD96ae5f866at ticket filing