Context
Observed 2026-08-03 on the one-machine local Docker Agent OS, after a host restart left the Neural Link Bridge unstarted. The neo-mjs-neural-link MCP server connected its transport, answered tools/list, then exited entirely ~2s later:
[INFO] Neural Link MCP Server transport connected
... method="tools/list" id=1 → result
[WARN] Disconnected from Neural Link Bridge
Error: connect ECONNREFUSED 127.0.0.1:8081
at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1705:16)
node:internal/process/promises:332 triggerUncaughtException(err, true /* fromPromise */);
[error] Server disconnected.The seat lost every Neural Link tool for the whole session. Observation and inference are separated below.
The Problem
ConnectionService already has a self-heal path: ensureBridgeAndConnect() connects to an existing Bridge, and spawns one via npm run ai:server-neural-link when that fails. It ran, and the spawn failed. From the bridge stdio log (<planeDataRoot>/logs/neural-link-bridge-stdio.log), stamped inside the crash window (npm debug logs 08:26:09.175Z, 08:26:09.413Z):
npm error path /package.json
npm error enoent Could not read package.json: ENOENT: no such file or directory, open '/package.json'
The spawn ran with cwd = the filesystem root. spawnBridge() resolves cwd: this.cwd || process.cwd(); a GUI-launched MCP server has process.cwd() === '/', so npm run finds no package.json and the Bridge never starts.
--cwd is supplied by the seat config, and Server.mjs does assign it — but the singleton is constructed at module import, so initAsync()'s auto-connect can begin before that assignment executes. Two consequences fall out of the same race:
spawnBridge() reads a still-null this.cwd and silently substitutes /.
- The resulting rejection is not inside the
try/catch that was written to keep the server alive — so the process dies instead of degrading.
Measured, not inferred: the Bridge binds 498 ms after npm run ai:server-neural-link, comfortably inside spawnBridge()'s fixed 2000 ms wait. Startup latency is not the cause.
Note also that this.cwd || process.cwd() is a hidden-default fallback: it silently substitutes a wrong value where it should fail loudly.
The Architectural Reality
ai/services/neural-link/ConnectionService.mjs:740 — spawnBridge(), cwd: this.cwd || process.cwd(), plus a setTimeout(resolve, 2000) that ignores its own startupDelayMs parameter.
ai/services/neural-link/ConnectionService.mjs:215-221 — initAsync() calls ensureBridgeAndConnect() when !Neo.config.unitTestMode && aiConfig.autoConnect.
ai/services/neural-link/ConnectionService.mjs:150-176, :799 — singleton: true, export default Neo.setupClass(ConnectionService): constructed at import.
ai/services/neural-link/ConnectionService.mjs:370-375 — ws.on('close') rejects; // Optional: Auto-reconnect logic could go here.
ai/mcp/server/neural-link/Server.mjs:183-191 — assigns ConnectionService.cwd = this.bridgeCwd, then await ConnectionService.ready(), inside a try/catch whose comment reads "Do not throw — server stays alive to report health errors via MCP healthcheck."
ai/mcp/server/neural-link/mcp-server.mjs:23, :44 — -w, --cwd <path> → bridgeCwd.
The ordering inside start() is correct. The defect is that import-time construction can outrun it.
The Fix
Both changes live in ai/services/neural-link/ + ai/mcp/server/neural-link/:
- Resolve the working directory instead of racing it. The Bridge spawn must read the entrypoint-provided cwd. An unresolved cwd must fail loudly with a named error rather than falling back to
process.cwd().
- A missing Bridge must be survivable. The auto-connect rejection must be handled on the path that actually runs it, so the server stays alive and reports the failure through
healthcheck — the behaviour Server.mjs:190 already declares but does not currently get.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
ConnectionService.cwd (ConnectionService.mjs:160) |
NL MCP entrypoint --cwd |
Resolved before any spawn can run |
None — fail loudly; remove the || process.cwd() substitution |
JSDoc on the member |
bridge stdio log ENOENT above |
spawnBridge({startupDelayMs}) (:740) |
same module |
Honor the parameter, or replace the fixed wait with a readiness probe |
n/a |
JSDoc |
measured 498 ms bind |
NL healthcheck tool |
ai/mcp/server/neural-link/Server.mjs |
Reports bridge-unreachable while the server stays alive |
n/a |
openapi.yaml |
crash log: today the server exits instead |
Decision Record impact
aligned-with ADR 0019 — this is not an AiConfig leaf, but this.cwd || process.cwd() is exactly the hidden-default-fallback pattern its §3 catalog forbids, and the fix should not introduce one.
Acceptance Criteria
Out of Scope
- Who owns/starts the Bridge in the containerized topology —
ai/deploy/hostEdgeProfile.mjs:113 explicitly un-elects NEO_ORCHESTRATOR_NL_BRIDGE_ENABLED. That is a topology decision, not this defect.
- Per-seat / per-worktree Bridge port allocation for multiple peers on one host.
- The dev-server lane (
NEO_ORCHESTRATOR_DEV_SERVER_ENABLED).
Avoided Traps
- Do not "fix" this by widening the 2 s spawn delay. Measured: the Bridge binds in 498 ms. Treating a wrong-cwd failure as a timing problem buries it.
- Do not swallow the rejection silently. The server must stay alive and surface the fault via
healthcheck; a bare catch recreates the catch-22 where an agent cannot observe the degradation it is subject to.
- Do not assume
--cwd was missing. It is supplied and assigned; the bug is ordering, so a config-plumbing "fix" would leave the race intact.
Related
#16008 — Neural Link Bridge stdio must fail without the injected logPath (CLOSED). Same spawnBridge() function; adjacent hardening precedent.
#14124 / #12972 — the embed-canary catch-22. Same "must stay observable while degraded" principle, applied here to the NL server's own liveness.
Origin Session ID: 6fbb7047-4b3f-4842-af7d-0aa5949dc392
Retrieval Hint: "Neural Link Bridge spawn cwd filesystem root ENOENT package.json"
Retrieval Hint: "ConnectionService singleton import-time initAsync beats bridgeCwd assignment"
Live latest-open sweep: checked the latest 20 open issues at 2026-08-03T08:43:35Z; A2A in-flight claim sweep over 30 messages (all read-states) at the same time. No equivalent found.
Context
Observed 2026-08-03 on the one-machine local Docker Agent OS, after a host restart left the Neural Link Bridge unstarted. The
neo-mjs-neural-linkMCP server connected its transport, answeredtools/list, then exited entirely ~2s later:[INFO] Neural Link MCP Server transport connected ... method="tools/list" id=1 → result [WARN] Disconnected from Neural Link Bridge Error: connect ECONNREFUSED 127.0.0.1:8081 at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1705:16) node:internal/process/promises:332 triggerUncaughtException(err, true /* fromPromise */); [error] Server disconnected.The seat lost every Neural Link tool for the whole session. Observation and inference are separated below.
The Problem
ConnectionServicealready has a self-heal path:ensureBridgeAndConnect()connects to an existing Bridge, and spawns one vianpm run ai:server-neural-linkwhen that fails. It ran, and the spawn failed. From the bridge stdio log (<planeDataRoot>/logs/neural-link-bridge-stdio.log), stamped inside the crash window (npm debug logs08:26:09.175Z,08:26:09.413Z):The spawn ran with
cwd= the filesystem root.spawnBridge()resolvescwd: this.cwd || process.cwd(); a GUI-launched MCP server hasprocess.cwd() === '/', sonpm runfinds nopackage.jsonand the Bridge never starts.--cwdis supplied by the seat config, andServer.mjsdoes assign it — but the singleton is constructed at module import, soinitAsync()'s auto-connect can begin before that assignment executes. Two consequences fall out of the same race:spawnBridge()reads a still-nullthis.cwdand silently substitutes/.try/catchthat was written to keep the server alive — so the process dies instead of degrading.Measured, not inferred: the Bridge binds 498 ms after
npm run ai:server-neural-link, comfortably insidespawnBridge()'s fixed 2000 ms wait. Startup latency is not the cause.Note also that
this.cwd || process.cwd()is a hidden-default fallback: it silently substitutes a wrong value where it should fail loudly.The Architectural Reality
ai/services/neural-link/ConnectionService.mjs:740—spawnBridge(),cwd: this.cwd || process.cwd(), plus asetTimeout(resolve, 2000)that ignores its ownstartupDelayMsparameter.ai/services/neural-link/ConnectionService.mjs:215-221—initAsync()callsensureBridgeAndConnect()when!Neo.config.unitTestMode && aiConfig.autoConnect.ai/services/neural-link/ConnectionService.mjs:150-176,:799—singleton: true,export default Neo.setupClass(ConnectionService): constructed at import.ai/services/neural-link/ConnectionService.mjs:370-375—ws.on('close')rejects;// Optional: Auto-reconnect logic could go here.ai/mcp/server/neural-link/Server.mjs:183-191— assignsConnectionService.cwd = this.bridgeCwd, thenawait ConnectionService.ready(), inside atry/catchwhose comment reads "Do not throw — server stays alive to report health errors via MCP healthcheck."ai/mcp/server/neural-link/mcp-server.mjs:23,:44—-w, --cwd <path>→bridgeCwd.The ordering inside
start()is correct. The defect is that import-time construction can outrun it.The Fix
Both changes live in
ai/services/neural-link/+ai/mcp/server/neural-link/:process.cwd().healthcheck— the behaviourServer.mjs:190already declares but does not currently get.Contract Ledger Matrix
ConnectionService.cwd(ConnectionService.mjs:160)--cwd|| process.cwd()substitutionspawnBridge({startupDelayMs})(:740)healthchecktoolai/mcp/server/neural-link/Server.mjsDecision Record impact
aligned-with ADR 0019— this is not an AiConfig leaf, butthis.cwd || process.cwd()is exactly the hidden-default-fallback pattern its §3 catalog forbids, and the fix should not introduce one.Acceptance Criteria
--cwdsupplied, starting the NL MCP server yields a running Bridge and a connected agent — witnessed from a live run, not inferred.process.cwd()is no longer a silent fallback.healthchecknames the failure; the process does not exit.127.0.0.1:8081must not terminate the server process.spawnBridge()either honorsstartupDelayMsor replaces the fixed wait with a readiness probe. (Contract correctness — measured bind is 498 ms, so this is not a latency fix.)Out of Scope
ai/deploy/hostEdgeProfile.mjs:113explicitly un-electsNEO_ORCHESTRATOR_NL_BRIDGE_ENABLED. That is a topology decision, not this defect.NEO_ORCHESTRATOR_DEV_SERVER_ENABLED).Avoided Traps
healthcheck; a bare catch recreates the catch-22 where an agent cannot observe the degradation it is subject to.--cwdwas missing. It is supplied and assigned; the bug is ordering, so a config-plumbing "fix" would leave the race intact.Related
#16008— Neural Link Bridge stdio must fail without the injected logPath (CLOSED). SamespawnBridge()function; adjacent hardening precedent.#14124/#12972— the embed-canary catch-22. Same "must stay observable while degraded" principle, applied here to the NL server's own liveness.Origin Session ID: 6fbb7047-4b3f-4842-af7d-0aa5949dc392
Retrieval Hint: "Neural Link Bridge spawn cwd filesystem root ENOENT package.json" Retrieval Hint: "ConnectionService singleton import-time initAsync beats bridgeCwd assignment"
Live latest-open sweep: checked the latest 20 open issues at 2026-08-03T08:43:35Z; A2A in-flight claim sweep over 30 messages (all read-states) at the same time. No equivalent found.