LearnNewsExamplesServices
Frontmatter
id10181
titleMemory-core stdio identity binding non-deterministic after DB re-seed
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-ada
createdAtApr 22, 2026, 5:54 PM
updatedAtJun 7, 2026, 7:20 PM
githubUrlhttps://github.com/neomjs/neo/issues/10181
authorneo-opus-ada
commentsCount0
parentIssue10139
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtApr 22, 2026, 6:04 PM

Memory-core stdio identity binding non-deterministic after DB re-seed

Closed v13.0.0/archive-v13-0-0-chunk-5 bugaiarchitecture
neo-opus-ada
neo-opus-ada commented on Apr 22, 2026, 5:54 PM

Memory-core stdio identity binding non-deterministic after DB re-seed

Context

Empirically surfaced in the 2026-04-22 Phase 4 post-merge validation cycle (sessions 7ee23599-3f94-4f75-b54c-97ba92c9aaef + successors). After the Phase 4 merge train shipped (#10175 / #10178 / #10177), tobi git pulled dev on both main checkouts and restarted Claude Desktop + Antigravity to smoke-test the live A2A channel. Binding failed on the first attempt. Diagnosis revealed the live SQLite had been polluted by a prior Playwright run that wrote test-fixture @opus / @gemini nodes AND wiped the production @neo-opus-ada / @neo-gemini-pro / @tobiu seeds. Re-ran node ai/scripts/seedAgentIdentities.mjs to restore them. Restarted again. Binding STILL failslist_messages errors with "Cannot list messages: no agent identity context bound" despite get_node({id: '@neo-opus-ada'}) returning the node correctly in the same session.

The Problem

The symptom is a read-vs-write asymmetry inside the same MCP server process:

Call Result
healthcheckmailboxPreview field null (indicates getAgentIdentityNodeId() returned null inside getHealthcheckPreview)
get_node({id: '@neo-opus-ada'}) Returns the node correctly
list_messages Throws Cannot list messages: no agent identity context bound
Direct SQLite SELECT id FROM Nodes WHERE id='@neo-opus-ada' Row exists

Environment is correct: NEO_AGENT_IDENTITY=neo-opus-ada is set in the running MCP process env (confirmed via ps -E), .neo-ai-data/sqlite/memory-core-graph.sqlite is the correct shared DB file opened by the process (confirmed via lsof), and the seed script succeeded against the same path (confirmed by post-seed row count).

The MCP server process was started at 17:46:59 local, after the seed at ~17:43 local — no timing race on the seed-before-boot side.

The Architectural Reality

Boot-time binding path (ai/mcp/server/memory-core/Server.mjs):

  1. initAsync line 115: this.stdioIdentity = await this.resolveStdioIdentity(); — sets the cached identity context for all subsequent tool dispatches
  2. resolveStdioIdentity (line 144): calls StdioIdentityResolver.resolve() → if githubLogin present, calls this.bindAgentIdentity(githubLogin)
  3. bindAgentIdentity (line 206): await GraphService.ready(), then GraphService.getNode({id: '@' + userId})?.id || null

Per-request dispatch (line 343):

const result = this.stdioIdentity
    ? await RequestContextService.run(this.stdioIdentity, dispatch)
    : await dispatch();

The RequestContextService.run() is wrapped only if this.stdioIdentity is truthy. If truthy but agentIdentityNodeId === null, the context IS set but with a null node-id — and downstream getAgentIdentityNodeId() returns null.

Lazy-load path (ai/graph/Database.mjs line 252-283):

GraphService.getNode calls db.getAdjacentNodes(id, 'both') first to trigger lazy SQLite load via storage.loadNodeVicinitySync(id). Per ai/graph/storage/SQLite.mjs line 297-330, loadNodeVicinitySync does SELECT data FROM Nodes WHERE id IN (?) — which SHOULD return the target node unconditionally (not only if it has edges).

However: db.vicinityLoadedNodes.add(nodeId) (Database.mjs line 282) marks the node as loaded regardless of whether the SQLite query returned anything. If a FIRST call at boot-time queries SQLite and returns nothing (for whatever reason — possibly a transient miss), the node is marked as "loaded" and subsequent getNode calls will NOT re-query — they'll only check the (still-empty) in-memory cache. This creates a stuck-in-null cache.

Theory to verify: at boot, resolveStdioIdentity's getNode({id: '@neo-opus-ada'}) fires before the SQLite WAL checkpoint from the seed is fully visible to a fresh connection. SQLite's better-sqlite3 default should surface WAL-committed writes immediately, but some boot-time timing could leave the fresh connection with a stale view. First query returns empty → node marked loaded → stdioIdentity.agentIdentityNodeId = null cached for the rest of the process lifetime.

The "subsequent get_node works" paradox is explained by: the vicinity-load happened at boot (before my session) and cached the "not found" result. But my direct sqlite3 query + the MCP tool call both succeed because... actually they wouldn't succeed if the vicinityLoadedNodes cache were stuck. So either:

  1. The vicinity-cache gets reset somewhere between boot and first tool call
  2. get_node has a different code path than getNode used by bindAgentIdentity — re-reading: same path. Both call db.getAdjacentNodes(id, 'both').
  3. Something between boot and first tool call adds @neo-opus-ada to the in-memory nodes store directly

Deeper investigation needed — the stuck-cache theory doesn't quite fit all observations.

Repro

  1. Populate SQLite graph DB with test-fixture nodes (e.g., run Playwright MailboxService.spec.mjs) such that production AgentIdentity nodes are absent
  2. Run node ai/scripts/seedAgentIdentities.mjs from main checkout to restore production nodes
  3. Restart Claude Desktop (or Antigravity)
  4. Call healthcheck → observe mailboxPreview: null
  5. Call list_messages → observe Cannot list messages: no agent identity context bound
  6. Call get_node({id: '@neo-opus-ada'}) → observe node returned correctly (contradicts the binding failure)

Proposed Fix Paths

Not mutually exclusive:

(a) Self-healing rebind at dispatch time. In Server.mjs tool dispatch, before RequestContextService.run, if this.stdioIdentity?.agentIdentityNodeId === null && this.stdioIdentity.userId, call this.bindAgentIdentity(userId) to attempt re-resolution. If it succeeds, update this.stdioIdentity.agentIdentityNodeId in-place. Self-healing — future dispatches use the resolved identity. Low risk; small blast radius.

(b) Fix the stuck vicinityLoadedNodes cache. In Database.mjs getAdjacentNodes, only call vicinityLoadedNodes.add(nodeId) IF vicinity.nodes.length > 0. If zero nodes returned, leave the cache unmarked so a retry can refresh. Risk: if SQLite is genuinely missing the node, we'd hit SQLite on every access — a minor cost. Guardable via LRU or TTL.

(c) Diagnostic surfacing via #10176 healthcheck identity block. Independent of the fix, the healthcheck response gains identity: {source, bound, nodeId} so this class of failure is diagnosable with one MCP call instead of SQLite spelunking + JSDoc reading. #10176 is already filed and addresses this specifically.

Recommended: ship (a) + (c) together as a hotfix. (b) is a follow-up once the race window is better understood.

Acceptance Criteria

  • Root cause confirmed via reproducible steps (run through the repro above on a clean checkout)
  • Fix path (a) self-healing rebind: if this.stdioIdentity.agentIdentityNodeId === null and userId is truthy, attempt rebind on dispatch; update in-place on success
  • Fix path (b) or equivalent: vicinityLoadedNodes cache does not stick on empty results (or the failure mode is structurally eliminated)
  • Playwright regression: simulate the "node added to SQLite AFTER first binding attempt" scenario, verify binding self-heals on subsequent dispatch
  • #10176 identity-surface healthcheck block (or merge this ticket with #10176 if timing aligns)

Out of Scope

  • SSE-transport identity binding path — unaffected per observation; SSE wraps per-request via TransportService with OIDC-derived context, no boot-time cache race.
  • ChromaDB read filtering — this ticket is about graph-node identity binding, not the tenant-filtered vector reads.

Avoided Traps

  • "Just always call RequestContextService.run unconditionally." Doesn't help — the issue is that stdioIdentity.agentIdentityNodeId is null, not that the wrap is missing.
  • "Force a full graph load at boot instead of lazy-load." Scope creep — the lazy-load primitive is architecturally correct at scale; fix the cache semantics, not the strategy.

Related

  • Sibling: #10176 (identity observability block in healthcheck — would make this class of failure mechanically diagnosable)
  • Adjacent: #10174 (seed-based AgentIdentity substrate this ticket exercises)
  • Test-isolation root cause follow-up (the :memory: override leak that let tests pollute the live DB in the first place — belongs to a separate ticket if we want to prevent the recurrence scenario entirely)

Origin Session ID: 7ee23599-3f94-4f75-b54c-97ba92c9aaef

tobiu referenced in commit fda5dd4 - "fix(memory-core): self-heal stdio identity binding on dispatch (#10181) (#10182) on Apr 22, 2026, 6:04 PM
tobiu closed this issue on Apr 22, 2026, 6:04 PM
tobiu referenced in commit 09984a9 - "fix(memory-core): auto-invoke wake subscription bootstrap on MCP server boot (#10437) (#10438) on Apr 27, 2026, 9:21 PM