Skip to content

feat(schema,daemon,desktop): fork on-disk state by channel - #303

Open
AprilNEA wants to merge 4 commits into
masterfrom
xuan/code-460
Open

feat(schema,daemon,desktop): fork on-disk state by channel#303
AprilNEA wants to merge 4 commits into
masterfrom
xuan/code-460

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

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 different WIRE_PROTOCOL_VERSION, and drop every frame in silence — surfacing only as "Unable to connect to the daemon". The one defence was remembering to pass LINKCODE_PROFILE=dev, which the devenv scripts did and a hand-typed pnpm -F … did not.

Channel now picks the whole on-disk universe.

release development
daemon state (config.json, daemon.db, runtime.json, hq.json, device-key.pem) ~/.linkcode ~/.linkcode.development
workspaces + chat root ~/LinkCode ~/LinkCode Development
managed asset store …/Application Support/LinkCode/assets …/LinkCode Development/assets

Release 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=development can never reach the development channel's directory. All naming lives in packages/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: injected LINKCODE_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 — a release-stamped bundle inside a development shell — is corrected by the supervisor's injection.

Resolution is per call, never cached at module load: instrument.ts derives a state path in its module body and --import runs it before index.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.profile to tell a double-start from a neighbour, and both channels' default profile is undefined — so a dev daemon hitting 19523 still exited 3. DaemonIdentity grows an optional channel (absent = release, which is what every pre-split daemon is) and the comparison becomes sameUniverse(). This is the HTTP discovery contract, not a wire message, so WIRE_PROTOCOL_VERSION is unchanged and an old daemon still reads correctly.

esbuild's define only substitutes a literal process.env.LINKCODE_BUILD_CHANNEL at the call site. The shared resolveProductChannel(injected, buildStamp) therefore takes two strings rather than an env object — passing process.env would 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 ~/.linkcode and 19523, and its GET /linkcode carries no channel field, so this also exercised the backward-compatible path.

Running pnpm -F @linkcode/daemon dev bare (which exited 3 before this change):

listener.bind  url=http://127.0.0.1:19524
// ~/.linkcode.development/runtime.json
{ "name": "linkcode-daemon", "pid": 94635, "channel": "development",
  "listeners": [{ "type": "socket.io", "url": "http://127.0.0.1:19524" }] }

Both stacks then ran side by side, and lsof -nP -i TCP showed no crossover: the dev Electron shell (userData: …/LinkCode Development) held a connection to 19524 while the release shell held one to 19523. ~/.linkcode gained no new files, and the dev daemon removed its own runtime.json on shutdown.

pnpm check:ci and pnpm 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 (stamped release) alongside a dev Electron shell (development); without an injected channel they land in different state dirs and the shell never finds runtime.json. Four harnesses now pass LINKCODE_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.ts keeps 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/app scripts no longer pass LINKCODE_PROFILE=dev, so the development universe moves from ~/.linkcode-dev to ~/.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.

AprilNEA added 4 commits July 28, 2026 00:57
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.
@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

CODE-460

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant