Skip to content

[bug] Brain Console reuses a stale detached server after updates and host restarts #79

Description

@sparkling

Summary

The Brain Console can remain on an old in-memory server indefinitely after the installed Console files have been updated.

Closing and reopening Codex or Claude Code does not correct it because the Console is deliberately launched as a background process. Once its host/tool process exits, the long-running Node server is reparented and is no longer owned by the next host session. The current launcher then probes only GET / and the text RuvNet Brain; any old Console that can render the page is treated as current and reused.

This produced a mixed-generation Console on a supported dual-host install:

  • the browser received the current UI assets from disk;
  • the Node process still had its pre-update API router in memory;
  • the UI called /api/capabilities;
  • the old server returned 404;
  • invoking the Console again printed “already running” and reopened the same incompatible process;
  • restarting Codex did not replace it.

There is a second activation gap: npx ruvnet-brain --update updates the bundle and host shells, but returns through runUpdate() before the full-install path calls installConsoleRuntime(). The persistent .console-runtime is therefore not part of that update transaction.

The Console needs a versioned, owned runtime-instance contract. A host restart is neither a safe nor a sufficient process supervisor.

Exact evidence

Verified on 2026-08-01 against public main at 12f31ce1bbfb785938573e350a09dad8da0542f0 and the supported installed runtime.

Mixed-generation process

The listener was:

pid       38770
ppid      1
started   2026-07-18 10:15:50
listen    127.0.0.1:7411
command   node ~/.claude/plugins/marketplaces/ruvnet-brain/scripts/onboarding-console.mjs --serve --open

The current marketplace and persistent Console runtime files were byte-identical and contained the /api/capabilities handler:

9080fb98cc52c084ec9a3c66f96f2aef22e860cac7160d0e4fd9ca2e02bfb2fe
  ~/.claude/plugins/marketplaces/ruvnet-brain/scripts/onboarding-console.mjs
  ~/.cache/ruvnet-brain/kb/.console-runtime/scripts/onboarding-console.mjs

But the live listener returned:

GET /api/capabilities HTTP/1.1

HTTP/1.1 404 Not Found
content-type: text/plain

not found

This is not an on-disk installation mystery. Node loaded the old module and route table on 18 July; replacing the file later did not hot-reload the running process.

The result is especially deceptive because serveStatic() reads the frontend files from disk per request. The old process can therefore serve the new console/app.js while retaining its old in-memory HTTP handlers: new page, old server, one apparently healthy port.

The existing-server check cannot distinguish generations

Current scripts/onboarding-console.mjs (lines 2707-2725 on the observed build) does this:

const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 800 }, (res) => {
  let b = '';
  res.on('data', (c) => { b += c; if (b.length > 4096) res.destroy(); });
  res.on('end', () => resolve(res.statusCode === 200 && /RuvNet Brain/.test(b)));
});

If that weak probe passes, it prints “already running” and opens the old URL. There is no API-contract version, runtime/source digest, process receipt, PID ownership proof, or graceful shutdown channel.

The native Claude command and Codex skill both explicitly launch the Console in the background and state that an already-running server is success. That is why its lifetime is independent of the host session.

The supported update path does not refresh the persistent Console runtime

bin/install.mjs has a correct atomic installConsoleRuntime(cacheDir) implementation, but it is reached only in the full install path near line 3946.

The top-level dispatcher sends --update to runUpdate(). That function:

  1. updates the bundle;
  2. runs syncHostsAfterUpdate() for Claude/Codex and the Stable Spine;
  3. exits with process.exit(updateStatus).

It never calls installConsoleRuntime(). Therefore an update can converge hooks/MCP/host payloads while the official runtime used by /rvbc and $ruvnet-brain:rvbc stays on an older generation.

Root cause

There are four connected failures:

  1. Unowned lifetime assumption — the Console is a background service, but it has no explicit instance ownership/lifecycle contract.
  2. Host-restart assumption — Codex/Claude restart does not and should not be assumed to reap an independently running user-level server, especially when several sessions may coexist.
  3. Weak compatibility probeGET / plus a product-name regex proves branding, not API or source compatibility.
  4. Incomplete update transaction — the persistent Console runtime is outside runUpdate() / host convergence, so current host code does not imply current Console code.

Required upstream implementation

The durable solution should stay user-level and host-neutral.

1. Put the persistent Console runtime in the update transaction

The same exact candidate that activates the bundle, Stable Spine, Claude plugin, and Codex plugin must atomically stage and verify .console-runtime.

  • Stage it from the exact candidate source.
  • Gate its syntax and required files before activation.
  • Record its version and source digest in the convergence receipt.
  • Preserve/restore the previous runtime on failure.
  • Do not report host convergence while the persistent Console runtime is still from another candidate.

This is the Console-specific completion of the broader release invariant in #77.

2. Give each Console instance a verifiable identity

On successful bind, atomically write a mode-0600 instance receipt under ~/.cache/ruvnet-brain containing at least:

  • product/schema identifier;
  • PID;
  • bound port;
  • start time;
  • project cwd/scope;
  • canonical script realpath;
  • Brain/runtime version;
  • source or build SHA-256;
  • a random instance/control token.

Expose a small read-only endpoint such as GET /api/runtime returning the non-secret identity fields. The endpoint must be cheap and must not depend on the heavy Console caches.

3. Reuse only an exact compatible instance

Before printing “already running,” the launcher must compare the running instance with the script it is about to execute.

Compatible means, at minimum:

  • expected product and runtime schema;
  • required API-contract version;
  • exact current source/build digest;
  • appropriate project scope if Console state is project-scoped.

A root page title is not compatibility evidence.

4. Replace only a receipt-proven owned stale instance

If a listener is a recognized Brain Console but its generation/digest is stale:

  • authenticate a graceful shutdown with the receipt’s control token;
  • wait for the owned listener to release the port;
  • start the current runtime;
  • atomically replace the receipt.

If the listener has no valid receipt, is a legacy Console, or is a foreign service, never kill it merely because it owns port 7411. Start the current Console on a free port, record that instance, and surface the legacy/foreign listener plainly. Subsequent launches should consult the receipt and reuse that current instance instead of spawning another random-port server.

On update, either restart a receipt-proven running Console safely or persist pending-console-restart and make the next Console launch perform the replacement. Do not claim that restarting Codex or Claude is sufficient.

5. Make doctor observe the running generation

--doctor should compare:

  • candidate/persistent Console runtime version and digest;
  • instance receipt;
  • live /api/runtime response;
  • PID/liveness;
  • required API contract.

It should report current, stale-running, legacy-unowned, foreign-port, or pending-console-restart rather than treating a branded root page as healthy.

Acceptance criteria

  • Start candidate A’s Console, replace the on-disk runtime with candidate B, and verify that launching the Console opens B rather than reusing A.
  • A B frontend served through an A in-memory router cannot pass health/compatibility.
  • A compatible current Console is reused without opening a second server.
  • An owned stale Console is gracefully replaced using a receipt/token proof.
  • A legacy or foreign process on 7411 is never killed.
  • A current random-port instance is persisted and reused; repeated invocations do not proliferate servers.
  • npx ruvnet-brain --update stages and verifies .console-runtime from the same exact candidate as the bundle and host payloads.
  • Update failure leaves both the prior Console runtime and prior running instance usable.
  • Codex-only, Claude-only, and dual-host fixtures pass the same lifecycle tests.
  • Exiting/restarting either host is not required for correctness and is not claimed to restart the Console.
  • Doctor fails or warns explicitly when live and on-disk Console identities differ.
  • A mixed-generation fixture reproducing GET /api/capabilities -> 404 is included.

Relationship to #77

#77 is the release-identity transaction: one immutable candidate across GitHub, npm, bundle, Stable Spine, both host plugins, and Console. This issue is the narrower long-running-process activation contract after bytes have changed, plus the concrete omission of .console-runtime from runUpdate().

Both need to hold:

Non-goals

  • Do not couple Console lifetime to one Codex or Claude session; multiple sessions may coexist.
  • Do not add a per-project Brain installation.
  • Do not kill arbitrary listeners by port/PID heuristics.
  • Do not make a downstream updater, cache mirror, pin, or version-specific entrypoint.
  • Do not treat “the browser page loaded” as proof that its API server matches it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions