Repository navigation
fix(cli): on a first run, os start's compile step writes to stderr, so the stdio MCP transport's stdout carries only JSON-RPC - #24214
Conversation
…MCP transport A first-run `os start` (no dist/objectstack.json) with OS_MCP_STDIO_ENABLED compiles the config in a one-shot child before it spawns `serve`. This pin asserts every stdout line through the `initialize` answer parses as a JSON-RPC frame, that the answer arrives, that the compile child's progress is on stderr, and that the compile leg really ran. WIP: red until the compile spawn stops inheriting fd 1. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…the stdio transport's stdout `start` routes its own stdout to stderr, but that patches process.stdout.write in this process only. The compile spawnSync handed its child the inherited fd 1, the fd the serve child's stdio MCP transport writes JSON-RPC frames to, so on a first run the compile's progress lines reached the client ahead of the initialize answer. The child's stdout is now this process's fd 2, unconditionally, matching the policy `run()` applies to everything else this supervisor prints. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
…he os start stdout-purity pin The new pin spawns bin/run.js with NODE_ENV unset, so it joins check:cli-test-child-env's pinned built-entrypoint population (eleven -> twelve), with the same deliberate-join note the #24002 pin carries. Its test-budget line is the slowest of three local runs (15.0 s) x 1.25. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
dispatch-gates flagged the tree as stale (scripts/check-route-envelope.mjs changed on main). The incoming commits touch no packages/cli path. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
📓 Docs Drift CheckThis PR changes 1 package(s): 18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 29 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 9710cf1132c4f6ded4d630ebc4a332548b7540a3 && git checkout 9710cf1132c4f6ded4d630ebc4a332548b7540a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 9fc58619b3bc55b4265b083bf8d3cd958d44dc93 && git checkout -B drift-repro b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 && git merge --no-ff 9fc58619b3bc55b4265b083bf8d3cd958d44dc93
node scripts/docs-audit/affected-docs.mjs --json b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761
|
席位核对 — ACCEPT · PR #24214 at
|
main brings #24212, which also joined check:cli-test-child-env's pinned built-entrypoint population (serve-mcp-stdio-early-initialize). Both branches bumped the self-test's count line from "eleven" to "twelve", so git merged the two bumps as one identical change while the list grew to thirteen entries. The count line now reads "thirteen"; both entries keep their deliberate-join notes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb
Fixes #24192
Clause-②: no
With
OS_MCP_STDIO_ENABLED=true, a first-runos start(nodist/objectstack.json) now writes only JSON-RPC frames to stdout. The automatic compile step's progress now goes to stderr, next to the rest ofos start's own output.What was wrong (measured on
f792290df, built CLI throughbin/run.js,NODE_ENVunset)start.tscallsredirectStdoutToStderr()(utils/json-stdout.ts) at the top ofrun(). That call patchesprocess.stdout.writein this process only. The compilespawnSyncgave its childstdio: ['ignore', 'inherit', 'inherit'], so the child wrote to the raw fd 1. That is the same fd theservechild's stdio transport writes JSON-RPC frames to.stdout carries 24 line(s) a JSON-RPC client would fail to parse. Those 24 lines are 19 text lines and 5 blank lines:◆ Compile, the rule, the→ …steps,✓ Build complete, theData:/UI:/Logic:/Security:/Runtime:summary andArtifact: …. All of them came ahead of theinitializeanswer frame. The card's 27 is the cli:os startwith OS_MCP_STDIO_ENABLED spawnsservewith stdio inherited and no config positional, so oclif reads the MCP client's first stdin line (initialize) as the config path; the boot exits 1 and initialize is never answered #24002 dev's count atfd16e46f6on another fixture. A host-plugin variant of this fixture also measured 24 here. The producer and the mechanism are the same; only the count differs.os startgets the same treatment. H2 asked whether the compile lines on stdout were the intended human output of a plainos start(stdio not enabled). Measured on the same first-run fixture with stdio OFF, unfixed build:os startprints was already on stderr: its own header (◆ ObjectStack,→ Compiling objectstack.config.ts → dist/objectstack.json...,Config:,Home:,Artifact:,Database:,Environment:,→ Starting server...) and the wholeservebanner.os start's output that was not on stderr.startdecides nothing by stdio mode.redirectStdoutToStderr()is UNCONDITIONAL there and inserve(os servewrites its banner and kernel logs to the stdout the stdio MCP transport owns #7915), andjson-stdout.tsgives the reason: a conditional needs a reliable signal at the moment each line prints, and it fails silently in the worse direction. So always-stderr is already the contract, and the fix does not branch on stdio mode.The fix (
packages/cli/src/commands/start.ts, the compilespawnSynconly)stdio: ['ignore', 'inherit', 'inherit']becomesstdio: ['ignore', 2, 'inherit'], so the compile child's stdout is this process's fd 2.'ignore'(cli:os startwith OS_MCP_STDIO_ENABLED spawnsservewith stdio inherited and no config positional, so oclif reads the MCP client's first stdin line (initialize) as the config path; the boot exits 1 and initialize is never answered #24002), and stderr stays inherited.--compileon a later run takes the same spawn, so it is covered too.servechild keepsstdio: 'inherit', because its transport owns fd 1.os compile's own output when you run it directly is unchanged (held off by the claim).Measured after the fix, plain
os start, same fixture: stdout carries 0 lines, and✓ Build completeis on stderr.Pin:
packages/cli/test/start-stdio-stdout-purity.integration.test.tsThe pin sits beside
serve-stdio-stdout-purity.e2e.test.ts, which pins the same invariant foros serve.Fixture and boots:
serve --devboot (stdio auto-start is fail-closed, ADR-0101), then one first-runos start.os startboot runs withOS_MCP_STDIO_ENABLED=trueand the minted key, withinitializewritten at spawn.On that one boot, the pin asserts:
initializeanswer and a 750 ms settle window after it, parses as a JSON-RPC 2.0 frame. Blank lines count.idand noerror, so the channel is live and not merely quiet.◆ Compileand✓ Build completeare on stderr: the output moved and was not deleted.dist/objectstack.jsonexists afterwards, so the boot really took the auto-compile leg.Tier and budget:
integrationtier: thevitest-tiers.tspredicate classifies it because it spawnsbin/run.js. The.integrationname keeps it on the queue run.test-budget: 20: 15.0 s, the slowest of three local runs (15.01 / 13.41 / 13.85 s), x 1.25. There is no CI reading yet.bin/run.jswithNODE_ENV: undefined, soscripts/check-cli-test-child-env.mjs's pinned built-entrypoint population gains it ("eleven" becomes "twelve"). It carries the same[#N] Joined the population deliberatelynote as the cli:os startwith OS_MCP_STDIO_ENABLED spawnsservewith stdio inherited and no config positional, so oclif reads the MCP client's first stdin line (initialize) as the config path; the boot exits 1 and initialize is never answered #24002 pin. This is the edit shape PR fix(cli): os start answers an MCP initialize written at spawn — its compile and serve children no longer read stdin as the config path #24184 used.Reverse verification. The fix was committed first (
45a7b8667). One leg, run under atrap … EXIT INT TERMrestore:scripts/ablation-replace.mjsswappedstdio: ['ignore', 2, 'inherit']back tostdio: ['ignore', 'inherit', 'inherit']. Anchor 1 became 0, and the blob went from733683e27b67to1dc9de46b023.ablation-dist-preflight.mjsfound the mutant marker indist/commands/start.js(exit 0).stdout carries 24 line(s) a JSON-RPC client would fail to parse (#24192).733683e27b67),git diff HEADis empty andgit statusis clean. After the rebuild, the mutant marker is absent fromdist/(--absent, exit 0) and the fix marker is present (exit 0).Local verification
8319dd69b(after mergingmain), run together with the cli:os startwith OS_MCP_STDIO_ENABLED spawnsservewith stdio inherited and no config positional, so oclif reads the MCP client's first stdin line (initialize) as the config path; the boot exits 1 and initialize is never answered #24002 pin:pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=1 test/start-stdio-stdout-purity.integration.test.ts test/start-mcp-stdio-early-initialize.integration.test.tsgave 2 files and 2 tests passed. The rest of theintegrationtier is declared to CI.81612d923:pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2gave 310 files and 4599 tests passed. The merge brought nopackages/clipath.pnpm --filter @objectstack/cli typecheckexited 0, andcheck:test-typecheckwas OK.tsc -p tsconfig.test.json --noEmit --listFileslists the new pin once, and it has no errors.pnpm exec eslint --no-inline-configover the three changed source files exited 0 with no output. The repo-widepnpm lintis left to CI.node scripts/check-cli-test-child-env.mjs --self-testpassed 152 cases. Before the population edit, it failed on exactly that case.8319dd69b:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstacknamed 85 commands. All 85 exited 0, each exit code captured before any pipe.--ranreconciled them:85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. The union includescheck:cli-test-child-env(14 built-CLI spawns, all out of development/test),check:pm-dispatch-gates(fast tier, 1884 cases),check:nul-bytes,check:dual-build-cjs-loadsandcheck:i18n-coverage. The derivation namesorigin/mainas 3 commits ahead after the merge, with one changed derivation input,scripts/check-route-envelope.mjs. That edit movespackages/restMODULES counts only, and this diff touches nopackages/restpath. CI runs on the merge ref.Acceptance notes
os startwith OS_MCP_STDIO_ENABLED spawnsservewith stdio inherited and no config positional, so oclif reads the MCP client's first stdin line (initialize) as the config path; the boot exits 1 and initialize is never answered #24002 pin's header.start-mcp-stdio-early-initialize.integration.test.tssays "⛔ Stdout PURITY is not asserted: the compile child's progress lines reach the same stdout. That is a separate defect, not this card's". After this PR the first half is no longer true, and the purity half is pinned here. The file is outside this claim's surface, so it is left as is. Noted only (carrier: none).Generated by Claude Code