feat(schema,daemon,desktop): fork on-disk state by channel - #303
feat(schema,daemon,desktop): fork on-disk state by channel#303AprilNEA wants to merge 4 commits into
Conversation
A local build and an installed release shared ~/.linkcode, ~/LinkCode, and the asset store, so only one could hold 19523: the loser's client then dialed a peer on a different WIRE_PROTOCOL_VERSION and every frame was dropped in silence. Channel now picks the whole universe, and the port hunt reads it off the occupant's identity so both daemons coexist. The daemon cannot see app.isPackaged, so it resolves its own channel: injected LINKCODE_CHANNEL, else the tsup build stamp, else development. Resolution is per call — instrument.ts derives a state path in its module body, before index.ts runs.
The dist bundle is stamped release while a dev Electron shell resolves as development, so a harness that spawns one without LINKCODE_CHANNEL leaves the two in different state dirs and the shell never finds runtime.json.
The devenv scripts no longer pin LINKCODE_PROFILE=dev: the development channel is its own universe now, so the profile axis goes back to meaning a second universe within one channel.
Both channels' default profile is undefined, so without comparing channel a dev daemon reads a release one as a double-start and exits 3 instead of hunting to the next port. An absent field still means release.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8df5a657b8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // Our own pid = another of this daemon's listeners hunted onto the port; another universe | ||
| // (channel × profile, absent fields = release × default) is isolated, not a double-start — | ||
| // hunt past both. This is what lets a dev daemon start while a release one holds 19523. | ||
| if (occupant && occupant.pid !== identity.pid && sameUniverse(occupant, identity)) { |
There was a problem hiding this comment.
Give development a non-overlapping port range
When a new development daemon occupies 19523 before a pre-CODE-460 installed release starts, the old daemon's Zod schema accepts the response while stripping the unknown channel field, then its old occupant.profile === identity.profile check treats both default profiles as the same daemon and exits 3. The release supervisor consequently stands down without a release runtime.json, so coexistence remains startup-order-dependent; use disjoint channel port ranges or another identity representation older daemons cannot misclassify.
AGENTS.md reference: apps/daemon/AGENTS.md:L40-L47
Useful? React with 👍 / 👎.
Closes CODE-460.
A local build and an installed release shared
~/.linkcode,~/LinkCode, and the managed asset store. Only one of them could hold 19523, and the loser did not fail loudly: the desktop supervisor stands down for an external daemon, so the release shell would discover and dial the dev daemon, hit a differentWIRE_PROTOCOL_VERSION, and drop every frame in silence — surfacing only as "Unable to connect to the daemon". The one defence was remembering to passLINKCODE_PROFILE=dev, which the devenv scripts did and a hand-typedpnpm -F …did not.Channel now picks the whole on-disk universe.
config.json,daemon.db,runtime.json,hq.json,device-key.pem)~/.linkcode~/.linkcode.development~/LinkCode~/LinkCode Development…/Application Support/LinkCode/assets…/LinkCode Development/assetsRelease paths are byte-identical to before, so an installed app keeps its sessions and HQ registration. A profile still appends
-<name>to the state dir only. The development suffix is dot-separated because profile names forbid dots —--profile=developmentcan never reach the development channel's directory. All naming lives inpackages/foundation/schema/src/product.ts, the one file a fork edits.The agent CLIs' own homes (
~/.claude,~/.codex) stay shared: separating them would force a second agent login and cut the daemon off from the CLI you use in a terminal.How the daemon knows its channel
It cannot see
app.isPackaged, so it resolves its own: injectedLINKCODE_CHANNEL→ the build-time stamp tsup bakes in (release) →development. Running the TS source is therefore a development daemon with nothing to remember, a packaged one is release, and the devshell pack — arelease-stamped bundle inside a development shell — is corrected by the supervisor's injection.Resolution is per call, never cached at module load:
instrument.tsderives a state path in its module body and--importruns it beforeindex.ts, which is exactly how CODE-166 leaked dev state into the release profile.Two things worth reviewer attention
Forking the paths was not enough. The port hunt used
occupant.profile === identity.profileto tell a double-start from a neighbour, and both channels' default profile isundefined— so a dev daemon hitting 19523 still exited 3.DaemonIdentitygrows an optionalchannel(absent = release, which is what every pre-split daemon is) and the comparison becomessameUniverse(). This is the HTTP discovery contract, not a wire message, soWIRE_PROTOCOL_VERSIONis unchanged and an old daemon still reads correctly.esbuild's
defineonly substitutes a literalprocess.env.LINKCODE_BUILD_CHANNELat the call site. The sharedresolveProductChannel(injected, buildStamp)therefore takes two strings rather than an env object — passingprocess.envwould defeat the substitution and every bundle would read as development. That is why daemon, assets, and agent-adapter each spell the literal out; the rationale is on the function.Verification
Driven with the installed 0.7.0 release running the whole time — it held
~/.linkcodeand 19523, and itsGET /linkcodecarries nochannelfield, so this also exercised the backward-compatible path.Running
pnpm -F @linkcode/daemon devbare (which exited 3 before this change):Both stacks then ran side by side, and
lsof -nP -i TCPshowed no crossover: the dev Electron shell (userData: …/LinkCode Development) held a connection to 19524 while the release shell held one to 19523.~/.linkcodegained no new files, and the dev daemon removed its ownruntime.jsonon shutdown.pnpm check:ciandpnpm test(1948 passed) are green.Not verified: the packaged devshell build on a real machine. The channel injection is covered by a supervisor unit test and by
packaged-smoke's state-dir assertion, but no packaged app was built and launched.E2E
The harnesses spawn
dist/index.js(stampedrelease) alongside a dev Electron shell (development); without an injected channel they land in different state dirs and the shell never findsruntime.json. Four harnesses now passLINKCODE_CHANNEL.packaged-smoke's state dir becomes.linkcode.development-<profile>— asserting the plain form there would prove the injection had been lost.apps/daemon/e2e/startup.e2e.tskeeps asserting.linkcode: it drives the bundle, so it now also pins the "built daemon = release" contract.Note for anyone with a dev setup
The devenv
daemon/desktop/appscripts no longer passLINKCODE_PROFILE=dev, so the development universe moves from~/.linkcode-devto~/.linkcode.development. That is a clean break, following the CODE-167 precedent — the old directory is left in place, and dev-side session history and HQ sign-in start fresh.