fix(cli): key the runtime state file by project, not by environment alone - #15968
Conversation
…lone
`os serve` publishes `{ pid, port, url, environmentId, startedAt }` under the
ObjectStack home so a supervisor can answer "is my server running, and where?".
The file was `runtime.<environment>.json`, and both halves of where it lived are
machine-global: `resolveObjectStackHome()` takes no arguments, and an environment
id is not a project identity. Two projects on one machine, both in the ordinary
`local` environment, wrote one file.
Driven with two real boots, two project roots and one home — two failures, one
cause:
* project B's boot replaced project A's record, so a reader asking about A was
answered with B's pid/port/url while A's server was still alive elsewhere;
* project A's shutdown then deleted the file that by then described B, leaving
a running server with no supervision record at all.
The file is now `runtime.<environment>.<project>.json`, keyed on the served
app's root — the root `serve` already resolves for host-anchored loads. The
payload is unchanged: no new key, and no database path (issue 15374 ruled that
one out deliberately, because it would turn a best-effort supervision file into
an identity contract).
The three in-repo readers that spelled the old name out now ask the writer's own
naming function instead, so a second copy of the rule cannot drift from it.
`scripts/publish-smoke.sh` globs `runtime.*.json` in a home it pins itself and is
unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
… a bare spread
`check:cli-test-child-env` is the gate that keeps `packages/cli/test`'s spawners
off `{ ...process.env }`: vitest sets `TEST`, `VITEST` and the `VITEST_*` family
on its worker, and a child inheriting them boots with a different auth and
crypto posture than the one under test. The new collision pin spawned its
children with a bare spread and the gate's shrink-only ratchet named it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8438cdc024aeec79a1f74aadbb373aedca92fe53 && git checkout 8438cdc024aeec79a1f74aadbb373aedca92fe53
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6615a024c33a9204a7a88273aa40b9ee107f78e3 db54f46a98e826d08acab8be3014c9ab875c6e1f && git checkout -B drift-repro 6615a024c33a9204a7a88273aa40b9ee107f78e3 && git merge --no-ff db54f46a98e826d08acab8be3014c9ab875c6e1f
node scripts/docs-audit/affected-docs.mjs --json 6615a024c33a9204a7a88273aa40b9ee107f78e3
|
os-litant
left a comment
There was a problem hiding this comment.
Contract review — clause ② verdict on the delivered diff at e18125843dd
Submitted as a COMMENT review rather than APPROVE / REQUEST_CHANGES because GitHub refuses both on a same-account PR; the verdict line below is the verdict.
What was reviewed: the diff d4c2cb196e8..e18125843dd (7 files, +532/−11), measured on a local merge of that head onto main at f50c394da55 (a merge commit built in a private worktree, never pushed; git diff --stat of the merge against main is exactly the PR's 7 files, +532/−11, so nothing in main's 16 newer commits overlapped). Judged from the delivered diff, not from the PR body.
Clause ② — the two limbs, judged separately
Mechanical floor: YES. The published payload gains no key — the JSON.stringify object literal is untouched (no added or removed line in the diff names pid, port, url, environmentId or startedAt) — and no path under packages/spec/src/** is touched, so neither of those two triggers fires; what fires is the floor's own wording in references/contract-review.md (any new exported symbol), because the diff adds export function projectStateKey and export function runtimeStateFileName to packages/cli/src/commands/serve.ts. Recorded so the PM seat can see which trigger decided it: those two exports are reachable only inside the package — @objectstack/cli's exports map declares ., ./console, ./hook-body and ./package.json, and src/index.ts re-exports only the ServeCommand default — so the floor triggers on the wording, not on a widening of the npm face. Graded YES under the doctrine that an unclear call is a yes.
Non-mechanizable conformance limb: YES. A shipped face moves: the on-disk name a supervisor opens under the ObjectStack home changes from runtime.ENVIRONMENT.json to runtime.ENVIRONMENT.PROJECT.json, so for one unchanged input class — a boot of some project in some environment — the published verdict of a reader of the old name is re-selected from "record present" to ENOENT, and the record's address is re-selected by a new identity component (the served app's root). That face was published: packages/cli/CHANGELOG.md for 17.3.0 (#13193, #13062) tells a supervisor to open runtime.env_local.json when the banner says ready, and scripts/publish-smoke.sh consumes it. It is the line #15374 declined to cross, approached from the path side rather than the payload side; the author's own declaration (PR §8) reads the same.
Verdict
CHANGES REQUESTED — one blocking item, on the changeset only; the code and the pin are sound.
Blocking
- The changeset does not carry the breaking-ness the diff performs. During the launch window the bump level is not the carrier (
scripts/check-changeset-no-major.mjsheader;.github/workflows/pr-automation.yml, "WHICH LEVEL"): a breaking change ships asminor, and its only signals are the**BREAKING**banner in the changeset body plus anadr-0087:disposition comment marker, whichscripts/check-adr-0087-registration.mjsthen re-verifies. This diff retires a shipped observable — the old file name — and the changeset's own paragraph says so ("a reader that hard-codesruntime.ENVIRONMENT.jsonnow getsENOENT"), yet it carries neither signal, so the release notes would list the rename of a supervisor-facing file as an ordinary minor with no BREAKING marker.
Reproduction on the PR head:grep -c BREAKING .changeset/runtime-state-file-project-key.mdprints0;grep -c adr-0087 .changeset/runtime-state-file-project-key.mdprints0;node scripts/check-adr-0087-registration.mjs --base origin/mainis green only because nothing is declared.
Fix: add the banner and the disposition marker. The category is the author's call —no-migration-prescriptionreads closest (no metadata migrates; the only prescription is "delete the stale file once") — and the gate re-verifies whichever category is claimed, so run it after the edit.minoris the right level under the window rule either way. If the position is that a best-effort, never-documented supervision file is not a published contract, that reasoning belongs in the changeset body as the stated reason no banner is carried; silence is the one shape the rule forbids.
Non-blocking — worth taking in the same round
packages/cli/src/commands/serve.tsnow states two names for the file: the new docblocks sayruntime.ENVIRONMENT.PROJECT.json, while theBoundPortChannels.writeRuntimeStateinterface doc (line 463, "Writesruntime.ENVIRONMENT.json") and the prose at lines 395, 479, 486, 495 and 4744 still name the old shape (runtime.env_local.json);packages/cli/test/helpers/serve-process.tslines 560 and 571 (error text) likewise. A reader of the interface is told the wrong name.- The project component is
servedAppRootOrCwd(), whichanchorServedAppsets to the config's directory only when that directory has apackage.json, else the CWD. So the same app served from two working directories without a manifest keys two files, and an out-of-tree supervisor has to replicate that anchoring rule to find the record. The docblock and changeset record the symlink boundary; this one belongs beside it.
Measured — every exit code captured before any pipe
- Gate union:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsover the 7 paths — 58 commands, asserted against the script's ownReconciliation — 58 famil(ies)line (46 by path + 7 by change kind + 7 whole-tree, 2 reached both ways; the "Local gates for this card" bullet block is 46 of those 58 and was not used as the population). All 58 GREEN on the merged tree afterturbo run build --filter='./packages/*' --filter='./packages/*/*', socheck:type-check-debt,check:dual-build-cjs-loadsandcheck:i18n*ran against real dist rather than exiting 3.--ranreconciliation:58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN. - Artifact rosters block (outside that total by the tool's design): 37 commands run separately — 34 GREEN; the 3 that are not are named under NOT MEASURED below, none of them red about this diff.
pnpm check:merge-driveron the merged tree, which includes #16002: exit 0.- Tests:
vitest runover the four affected files on the merged tree — 4 files / 43 tests passed, exit 0.pnpm --filter @objectstack/cli typecheck— exit 0,tsc --noEmitandcheck:test-typecheckboth echoed. - Discriminating control on the pin (prediction written before the run: 5 RED / 7 GREEN, the reds named): reverting only the write site to the pre-repair line with both helpers intact — blob
4745c41…→bd08f51…, pre-repair shape count 1, repaired-call count 0 — gave5 failed | 7 passed (12), exit 1, the five by name (own file, shared name, project-not-process control, shutdown, COUNTS). Restored by state, not by trap: porcelain empty, blob back to4745c41…, marker count 0. The pin fails on the axis that can actually fail. - Reader enumeration re-derived with a control: a repo-wide grep for the name-constructing spellings finds exactly the writer, the four in-repo tests,
scripts/publish-smoke.sh(globsruntime.*.json, unaffected) and the spec smoke-shape fixture;content/,docs/andskills/name the file nowhere (grep exit 1) while the control patternOS_HOMEhits 7 files in the same roots with the same include set. Payload literal byte-identical, confirmed on the diff. - CI at head
e18125843dd: every check run completed as success or skipped-by-design; the six required contexts (Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL)) are success.
NOT MEASURED — by name, each with its own verdict line
pnpm --filter @objectstack/spec run check:react-declaration-parity— exit 1 with its own text "This gate did NOT run" (MANIFESTunset; needs the objectui console build plus a browser). PREREQUISITE NOT MET; an on-demand gate by decision, not a CI job; not attributed to this diff.node scripts/check-partof-closing-keyword.mjs— exit 2, NOT WIRED (noPR_BODY/PR_NUMBER). CI's "Part-of PR must not also close its card" is success on this head.node scripts/check-single-claim-paths.mjs— exit 2, NOT WIRED (noPR_NUMBER). CI's "No other open PR may claim the same single-writer path" is success on this head.- The 6 value-bearing families (
check-cross-package-test-inputs --union-into …,check-shard-attestation×3,check-test-completeness×2) — no local invocation exists by design; their CI jobs (Test Core 1–6/6, Dogfood Regression Gate 1–3/3, Dogfood Verify CLI) are success on this head. node scripts/pm/check-clause2-carriers.mjs --pair 15968— exit 3, PREREQUISITE NOT MET (HTTP 403 from this container). Read by hand instead:needs:contract-reviewsits on both carriers. Note for the PM seat: the card's claim comment carries no machine-spelledClause-②: yesline — the declaration lives in PR §8 and the dev report as prose, which that checker does not read.- Sibling checkouts
objectuiandcloudare not present in this container; the PR's "zero readers there" claim is unverified here. - Repo-wide
pnpm lintwas not re-run locally; CI'sLint & Repo Gatesis success on this head.
Generated by Claude Code
…geset 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
Clears staleness ahead of the contract-review patch round and picks up #16002. Measured before the merge: `pnpm check:merge-driver` was already exit 0 on the pre-merge head, so the branch was not red on that gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
|
os-dev-report Generated by Claude Code |
Clause ② carriers cleared — contract review satisfiedContract review #pullrequestreview-5122785090 returned CHANGES REQUESTED with one blocking item, and that item is closed at head
Both label sets were read before the write and read back after; the read's positive control is that it had to see No second tier round, and whyThe seat rules this closed without sending it back to the review tier. The change is confined to the changeset's two breaking-ness carriers — the What was verified before clearing
Still open, handed back rather than folded in
Generated by Claude Code |
Fixes #15733
1 — The card's own disclaimer, settled first: the collision is DRIVEN
The card was careful to say what it had not done:
It has now been driven. Two real
os serveboots, two different project roots, one home, both in the ordinarylocalenvironment. Verbatim from the run:⇒ The failure mode is an observation now, not a reading. It did not fail to reproduce, and no path component, lock or per-project root saved it.
A SECOND consequence the card did not assert, measured in the same run:
runtimeBoundPortChannelsregistersprocess.on('exit', cleanupRuntimeFile)per file written. While every project shared one file, any project's shutdown deleted the record of whoever was still serving. A wrong answer and then no answer at all, from one cause.Zero-write proof of the machine-global half, so nothing under the real
~/.objectstackwas disturbed (other agents share this box; the driven run pinnedOS_HOMEto one sandbox dir, which is exactly what the default resolves to for every project):The control matters: it proves the resolver is not simply a stuck constant, so "same path from two cwds" is a reading about cwd.
2 — The two facts re-derived, not quoted
packages/cli/src/commands/serve.ts(runtimeBoundPortChannels.writeRuntimeState, on the base commit) wrote exactly{ pid, port, url, environmentId, startedAt }— no database path, no project path — topath.join(resolveObjectStackHome(), 'runtime.' + environmentId + '.json').resolveObjectStackHome(packages/runtime/src/standalone-stack.ts) takes no arguments:OS_HOME, elsehomedir()/.objectstack. Both halves confirmed.3 — Who reads this file (measured, with a control that fires)
scripts/publish-smoke.shsmoke_wait_for_own_serverruntime.*.jsonunder a TMPDIR it pins itself,--freshpackages/cli/test/serve-publishes-bound-port.e2e.test.tspackages/cli/test/serve-bound-port-publish-order.test.tspackages/cli/src/commands/serve-bound-port-publication.test.tspackages/spec/scripts/publish-smoke-port-collision.test.tsNothing else. Zero readers in
content/,docs/,skills/; zero in the siblingobjectuicheckout; zero in any other package.Control on that search, same invocation, same include set, same root: the pattern
OS_HOMEreturns 120 hits across ts / mdx / md / json / sh / yml, and inobjectuia control of 716.tsfiles matchingobjectstackagainst 0 hits for the reader patterns. The near-empty reader result is a reading, not a silent zero.⭐ Note what the smoke script already says in its own comment: "a neighbouring run's server answers 200 on the requested port exactly like ours would" — it hand-rolled a per-run isolated home to work around precisely this class. The repo has already paid for this defect once.
4 — The repair shape, and why (i)
The dispatch named three shapes and made no ruling. The reader enumeration decided it.
Taken: (i) key the file by a project identity.
runtime.ENVIRONMENT.PROJECT.json, where the project component is a sanitised basename plus a 12-hex digest of the served app's root —servedAppRootOrCwd(), the rootservealready resolves for host-anchored package loads. No new concept was introduced; the state file simply was not using the identity this command had already computed.publish-smoke.shcarries a hand-rolled workaround for this exact class, and the card's own filer says working across two projects at once is that seat's ordinary state. Read as real but unreported, not as hot.⛔ #15374's ruling is untouched. No database path, and no payload key of any kind — the object literal is byte-identical. The identity of the file a process is serving stays a property of that process.
Honest boundaries, stated rather than papered over:
ENOENTinstead of a stale or foreign record. That is the intended trade: a missing file is a loud, correct answer where the old name could only give a confident wrong one.5 — The pin, and its controls
packages/cli/test/serve-runtime-state-project-key.test.ts— 12 tests. Three real child processes, one shared home, two project roots; each writes through the realruntimeBoundPortChannels. No server is booted: the name is what is under test.overwritesObservedis asserted=== 1, not>= 0.childrenSpawned === 3, asserted, not printed.6 — Ablation: prediction written first, then run
Predicted in writing before the mutation (mutation: revert only the write site to the pre-repair line, leaving
projectStateKey/runtimeStateFileNameintact, so a red is about the wiring and not about the helper vanishing): 5 RED / 7 GREEN, naming all five reds and giving each green its reason — the five pure-function tests survive because the ablation removes the wiring, not the helper; the two controls survive because they assert on what the children reported and on the untouched helper.Observed: 5 failed | 7 passed (12) — the same five, by name.
Mutation proved on disk before the run:
Source-vs-
distresolution, established positively:packages/cli/distdid not exist in the worktree at ablation time (only the dependency closure had been built), and both the runner and the spawned child reach the writer by a relative import intopackages/cli/src. A run resolving through the package name todist/would have failed loudly, never passed quietly — and the pre-ablation run was green, which a staledistbuilt from the base commit could not have produced.Restore, under an
EXIT INT TERMtrap, absolute paths, viagit hash-object -w+git cat-file blob(⛔ nevergit checkout HEAD --, which is wrong mid-merge), verified three independent ways rather than by the trap having fired:7 — Verification
pnpm --filter @objectstack/cli exec vitest runover the four affected files ate18125843dd: 4 files / 43 tests passed.pnpm --filter @objectstack/cli typecheck— green (tsc --noEmit+check:test-typecheck, both script names echoed).Reconciliationline ("58 famil(ies)"), not against a bullet block or apnpm check:grep. Re-derived after the change set was final; the command list is byte-identical across both derivations. All 58 green ate18125843dd. Four of them first reported PREREQUISITE NOT MET (check:dual-build-cjs-loads,check:i18n,check:i18n-coverageat exit 3;check:i18n-walk-parityat exit 1 with "NOTHING was measured" in its own verdict) — read as NOT MEASURED, never as pass;@objectstack/cli+@objectstack/specwere built and all four then returned real green verdicts. One real finding was fixed rather than baselined:check:cli-test-child-env's shrink-only ratchet named the new pin's bare{ ...process.env }spawn, which now goes throughchildEnv().pnpm lintis CI's run; delivered here as a proven narrowing, with all three pieces of evidence: (a) the population is read from ESLint's own config — all 6 changed source files come back[LINTED], none[IGNORED BY CONFIG]; (b) the count is ESLint's own, from--format json: 6 files, 0 errors, 0 warnings, exit 0; (c) the invariance claim for untouched files is the config's own, stated ineslint.config.mjs: this repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file" — so no edit in this diff can move a verdict on a file it does not touch.cmd > log 2>&1; EXIT=$?), and every gate verdict is quoted from the gate's own verdict line.8 — Clause ② declaration, from the delivered diff — YES
Graded conservatively, per the doctrine that an unclear call is graded yes. Both limbs, separately:
packages/spec/src/**. The published payload gains no key — the object literal is byte-identical. What triggers the floor is that the diff adds two newexport functions (projectStateKey,runtimeStateFileName) to a shipped module. Mitigating fact, recorded rather than used to argue the grade down:packages/cli'sexportsmap declares only.,./consoleand./hook-body, socommands/serveis not a declared public subpath.runtime.ENVIRONMENT.jsontoruntime.ENVIRONMENT.PROJECT.json, so for one unchanged input (a boot in some project) the published verdict changes from "a record exists here" toENOENT. That is the same line A boot whose SQLite database file has been unlinked keeps serving it silently, so every filesystem inspection describes a different file #15374 declined to cross, approached from the path side rather than the payload side, and it is the reviewer's call — not something a p3 finding gets to assume away.needs:contract-reviewis applied to this PR and to the card together.9 — Out-of-scope finding, handed back with its measurement
packages/runtime/src/package-state-store.tshas the same defect class in a different file:stateFilePath()isjoin(resolveObjectStackHome(), 'package-state', sanitizeEnvironmentId(environmentId) + '.json')— the same machine-global home, keyed by environment id alone. Two projects on one machine therefore share which packages an operator has disabled, and that one changes behaviour rather than only reporting it.⛔ Deliberately not repaired here. It fails the bounded-in-place test on two counts: it is a different package with a different gate surface, and — more importantly — whether package-lifecycle state should be per-project is a contract judgement, not a mechanical repair. An operator may legitimately want "disabled in staging" to be machine-wide. Dedup was run before filing, with a live positive control (the query returned this card, #15733, so the result is a reading and not a silent zero); no existing card covers it.
Generated by Claude Code