Skip to content

Commit c3fe7ea

Browse files
committed
docs(cli): carry the runtime-state rename's breaking-ness in the changeset
The launch window refuses `major`, so the bump level is not the carrier: a breaking change ships as `minor` and its only signals are the BREAKING banner in the changeset body and the ADR-0087 disposition marker that `check-adr-0087-registration.mjs` re-verifies. This diff retires a shipped observable -- the on-disk name a supervisor opens -- and the changeset said so in its own prose while carrying neither signal. Adds both. The disposition is `not-required (no-migration-prescription)`, derived from the gate's own `CATEGORIES` const rather than its prose header: nothing authorable moves, `packages/spec` is untouched, and what is retired is a path on disk rather than a metadata surface the ledger can project into `spec-changes.json` or the upgrade guide. `unpublished` is false (the package publishes), `already-registered` names no entry that covers this, and neither `runtime-interface-only` nor `type-surface-only` has a `path#Symbol` to name. Also ends the two names `serve.ts` was stating at once. The `BoundPortChannels.writeRuntimeState` interface doc and the four other current-tense sites named the retired file; they now name the shipped one, while the measured ENOENT quote and the pre-repair collision diagram keep the name they observed and say that is what it was. The anchoring boundary an out-of-tree reader has to replicate -- the app root is the config's own directory only when that config exists and that directory carries a `package.json`, else the CWD -- is stated beside the symlink boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
1 parent e181258 commit c3fe7ea

2 files changed

Lines changed: 35 additions & 17 deletions

File tree

.changeset/runtime-state-file-project-key.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
`os serve`'s runtime state file is keyed by the PROJECT, not by the environment id alone — so two projects on one machine stop overwriting each other's supervision record.
66

7+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, export, config field or stored metadata changes spelling or shape, `packages/spec` is untouched, and `objectstack migrate meta` has nothing to rewrite. What is retired is the NAME of a best-effort supervision file that `os serve` writes under the ObjectStack home — a path on disk, not a metadata surface the ledger can project into `spec-changes.json` or the generated upgrade guide. The affected party is an out-of-tree supervisor that opens that path, and the one action it takes is deleting a single stale file; there is no authored artifact for a metadata upgrader to rewrite, and no ledger entry could reach the party that is affected. -->
8+
9+
**BREAKING** for anything that opens the runtime state file by its old name. Shipped as `minor` under the launch-window convention: while the whole workspace versions in lockstep the bump level carries no breaking-ness, so this banner and the ADR-0087 disposition above are the carriers. The file `os serve` writes under the ObjectStack home was named `runtime.<environment>.json` and is now named `runtime.<environment>.<project>.json`.
10+
711
`os serve` publishes `{ pid, port, url, environmentId, startedAt }` to a file under the ObjectStack home, so a supervisor can answer *"is my server running, and where?"*. That file was named `runtime.<environment>.json`, and both halves of where it lived were machine-global: `resolveObjectStackHome()` takes no arguments (it reads `OS_HOME`, else `~/.objectstack`), and an environment id is not a project identity. Two different projects on one machine, both in the ordinary `local` environment, therefore wrote one file.
812

913
Driven with two real boots, two project roots and one home, that produced two failures with one cause:
@@ -15,4 +19,6 @@ The file is now `runtime.<environment>.<project>.json`, where the project compon
1519

1620
**If you read this file:** a reader that hard-codes `runtime.<environment>.json` now gets `ENOENT` rather than a stale or foreign record — a loud, correct answer to "is my server running", where the old name could only give a confident wrong one. Readers that glob `runtime.*.json` inside a home they pinned themselves (as `scripts/publish-smoke.sh` does) are unaffected. A `runtime.<environment>.json` left over from an earlier version is no longer written or cleaned up by `os serve`; delete it once.
1721

18-
Two boots of the *same* project still share one file, which is the same-project case and unchanged here.
22+
**Which root the project component is taken from**, for a supervisor that has to reconstruct the name out of tree: it is the app root `serve` anchors at, which is the config file's own directory when that file exists and that directory carries a `package.json`, and the process's working directory otherwise. Two boundaries follow, stated rather than fixed: the same app served from two working directories without a manifest keys two files, and the key is the resolved path rather than the realpath, so two symlinked spellings of one project key differently — each spelling gets its own file, and each is internally consistent.
23+
24+
Two boots of the *same* project from the *same* anchor still share one file, which is the same-project case and unchanged here.

packages/cli/src/commands/serve.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,9 @@ export function formatExhaustedPortSearchNotice(requestedPort: number, cause: un
392392
* `serve` used to publish the port it was ASKED for on all three of the
393393
* channels that ANNOUNCE an address — the `objectstack:listening` IPC message,
394394
* the ready banner's `API:` row (through {@link resolveAuthBaseUrl}), and
395-
* `runtime.<environment>.json`. For every port but one the requested and the
396-
* bound value coincide, which is why it stayed invisible; for `0` they CANNOT
397-
* coincide. `MIN_PORT = 0` is legal on purpose — `utils/port-contract.ts` says
395+
* `runtime.<environment>.<project>.json`. For every port but one the requested
396+
* and the bound value coincide, which is why it stayed invisible; for `0` they
397+
* CANNOT coincide. `MIN_PORT = 0` is legal on purpose — `utils/port-contract.ts` says
398398
* so in its own words, from its own measurement, that 0 is "a REQUEST, not an
399399
* error", and `listen(0)` binds a kernel-assigned port. So `os serve --port 0`
400400
* announced `{ port: 0 }`, printed `API: http://localhost:0/` and wrote
@@ -460,8 +460,8 @@ export interface ListeningMessage {
460460
*/
461461
export interface BoundPortChannels {
462462
/**
463-
* Writes `runtime.<environment>.json`. ⛔ Must COMPLETE before either
464-
* announcement below: it is the file both of them send a consumer to.
463+
* Writes `runtime.<environment>.<project>.json`. ⛔ Must COMPLETE before
464+
* either announcement below: it is the file both of them send a consumer to.
465465
*/
466466
writeRuntimeState: (published: { port: number; url: string }) => void;
467467
/** Sends {@link ListeningMessage}, when an IPC channel is open. */
@@ -476,14 +476,14 @@ export interface BoundPortChannels {
476476
* ## The bug this shape exists to make impossible
477477
*
478478
* `os serve` announces its address on three channels: the runtime state file
479-
* `runtime.<environment>.json`, the `objectstack:listening` IPC message, and
480-
* the ready banner. Two of those are ANNOUNCEMENTS a consumer reacts to; the
481-
* third is the FILE those consumers then open. Published in the order they
479+
* `runtime.<environment>.<project>.json`, the `objectstack:listening` IPC
480+
* message, and the ready banner. Two of those are ANNOUNCEMENTS a consumer
481+
* reacts to; the third is the FILE those consumers then open. Published in the order they
482482
* happened to be written — banner, IPC, file — every consumer that believes an
483483
* announcement races a file that is not there yet:
484484
*
485485
* ```text
486-
* banner ─▶ a supervisor sees "ready" and opens runtime.env_local.json
486+
* banner ─▶ a supervisor sees "ready" and opens runtime.env_local.<project>.json
487487
* IPC ─▶ the `os dev` parent sees the port
488488
* file ─────────────────────────▶ ...written here. The ENOENT already happened.
489489
* ```
@@ -492,10 +492,11 @@ export interface BoundPortChannels {
492492
* claim (`serve-publishes-bound-port.e2e.test.ts`) is an ORDINARY consumer — it
493493
* waits for the banner AND the IPC message, then reads the file — and it
494494
* ejected 14 PRs from the shared merge queue in a rolling 24 hours (10
495-
* independent hits, #13158) with `ENOENT: ... runtime.env_local.json`. A real
496-
* supervisor written the same way loses the same race; all a loaded machine
497-
* does is deschedule the child between the announcement and the write, which is
498-
* why it read as a flake for a day.
495+
* independent hits, #13158) with `ENOENT: ... runtime.env_local.json` — the
496+
* name that file carried then, before {@link runtimeStateFileName} keyed it by
497+
* project as well. A real supervisor written the same way loses the same race;
498+
* all a loaded machine does is deschedule the child between the announcement
499+
* and the write, which is why it read as a flake for a day.
499500
*
500501
* ⛔ The repair is NOT to make the reader poll. A consumer that must poll after
501502
* being told "ready" was told "ready" too early — polling spreads the defect
@@ -572,6 +573,17 @@ export function publishBoundPort(boundPort: number, channels: BoundPortChannels)
572573
* spelling gets its own file, and each is internally consistent). Two boots of
573574
* the SAME project also still share a file — that is the same-project case,
574575
* which is #15374's in-process watch, not this one.
576+
*
577+
* ⚠️ Second boundary, and the one an OUT-OF-TREE reader has to replicate to
578+
* find the record: WHICH root this is handed is {@link servedAppRootOrCwd},
579+
* which {@link anchorServedApp} sets to the CONFIG'S OWN DIRECTORY only when
580+
* that config exists and that directory carries a `package.json`, and to
581+
* `process.cwd()` otherwise. So one app served from two working directories
582+
* with no manifest beside its config keys TWO files, and a supervisor that
583+
* reconstructs the name out of tree has to apply that same rule rather than
584+
* assume the config's directory. That fallback is #11185's and is deliberate:
585+
* a directory that declares nothing is not anchored at, because anchoring
586+
* there could only turn a working boot into an `undeclared` refusal.
575587
*/
576588
export function projectStateKey(servedAppRoot: string): string {
577589
const absolute = path.resolve(servedAppRoot);
@@ -4741,9 +4753,9 @@ export default class Serve extends Command {
47414753
// ── The port this process ACTUALLY bound (#13062) ─────────────
47424754
// Read ONCE, here, and handed to every channel that announces an address:
47434755
// the ready banner below, the `objectstack:listening` IPC message and
4744-
// `runtime.<environment>.json`. Those three were three outputs of ONE
4745-
// number, and that number was the port that had been REQUESTED — equal to
4746-
// the bound one for every value except the one where it can never be
4756+
// `runtime.<environment>.<project>.json`. Those three were three outputs
4757+
// of ONE number, and that number was the port that had been REQUESTED
4758+
// — equal to the bound one for every value except the one where it can never be
47474759
// (`--port 0`), which is how all three came to announce `localhost:0`
47484760
// with nothing erroring.
47494761
//

0 commit comments

Comments
 (0)