Skip to content

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

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24192-start-compile-stdout-purity
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24192-start-compile-stdout-purity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24192

Clause-②: no

With OS_MCP_STDIO_ENABLED=true, a first-run os start (no dist/objectstack.json) now writes only JSON-RPC frames to stdout. The automatic compile step's progress now goes to stderr, next to the rest of os start's own output.

What was wrong (measured on f792290df, built CLI through bin/run.js, NODE_ENV unset)

  • H1 holds. start.ts calls redirectStdoutToStderr() (utils/json-stdout.ts) at the top of run(). That call patches process.stdout.write in this process only. The compile spawnSync gave its child stdio: ['ignore', 'inherit', 'inherit'], so the child wrote to the raw fd 1. That is the same fd the serve child's stdio transport writes JSON-RPC frames to.
  • Re-measured once, with stdio enabled: the new pin, run against the unfixed build, went RED with 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, the Data:/UI:/Logic:/Security:/Runtime: summary and Artifact: …. All of them came ahead of the initialize answer frame. The card's 27 is the cli: os start with OS_MCP_STDIO_ENABLED spawns serve with 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 at fd16e46f6 on 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.
  • H2 is falsified: the plain os start gets the same treatment. H2 asked whether the compile lines on stdout were the intended human output of a plain os start (stdio not enabled). Measured on the same first-run fixture with stdio OFF, unfixed build:
    • stdout carried exactly 19 lines, and every one came from the compile child.
    • Everything else os start prints was already on stderr: its own header (◆ ObjectStack, → Compiling objectstack.config.ts → dist/objectstack.json..., Config:, Home:, Artifact:, Database:, Environment:, → Starting server...) and the whole serve banner.
    • The compile lines were the one part of os start's output that was not on stderr. start decides nothing by stdio mode. redirectStdoutToStderr() is UNCONDITIONAL there and in serve (os serve writes its banner and kernel logs to the stdout the stdio MCP transport owns #7915), and json-stdout.ts gives 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 compile spawnSync only)

stdio: ['ignore', 'inherit', 'inherit'] becomes stdio: ['ignore', 2, 'inherit'], so the compile child's stdout is this process's fd 2.

Measured after the fix, plain os start, same fixture: stdout carries 0 lines, and ✓ Build complete is on stderr.

Pin: packages/cli/test/start-stdio-stdout-purity.integration.test.ts

The pin sits beside serve-stdio-stdout-purity.e2e.test.ts, which pins the same invariant for os serve.

Fixture and boots:

  • One minting serve --dev boot (stdio auto-start is fail-closed, ADR-0101), then one first-run os start.
  • The os start boot runs with OS_MCP_STDIO_ENABLED=true and the minted key, with initialize written at spawn.
  • Both are bounded children: a detached process group, SIGTERM then SIGKILL, and a 120 s answer deadline.

On that one boot, the pin asserts:

  1. Every complete stdout line, through the initialize answer and a 750 ms settle window after it, parses as a JSON-RPC 2.0 frame. Blank lines count.
  2. The answer carries the request's own id and no error, so the channel is live and not merely quiet.
  3. The compile child's ◆ Compile and ✓ Build complete are on stderr: the output moved and was not deleted.
  4. Positive control: dist/objectstack.json exists afterwards, so the boot really took the auto-compile leg.

Tier and budget:

Reverse verification. The fix was committed first (45a7b8667). One leg, run under a trap … EXIT INT TERM restore:

  • scripts/ablation-replace.mjs swapped stdio: ['ignore', 2, 'inherit'] back to stdio: ['ignore', 'inherit', 'inherit']. Anchor 1 became 0, and the blob went from 733683e27b67 to 1dc9de46b023.
  • The CLI was rebuilt. ablation-dist-preflight.mjs found the mutant marker in dist/commands/start.js (exit 0).
  • The pin went RED: stdout carries 24 line(s) a JSON-RPC client would fail to parse (#24192).
  • Restored: the blob equals HEAD (733683e27b67), git diff HEAD is empty and git status is clean. After the rebuild, the mutant marker is absent from dist/ (--absent, exit 0) and the fix marker is present (exit 0).

Local verification

  • Pin at 8319dd69b (after merging main), run together with the cli: os start with OS_MCP_STDIO_ENABLED spawns serve with 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.ts gave 2 files and 2 tests passed. The rest of the integration tier is declared to CI.
  • Unit tier at 81612d923: pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 gave 310 files and 4599 tests passed. The merge brought no packages/cli path.
  • pnpm --filter @objectstack/cli typecheck exited 0, and check:test-typecheck was OK. tsc -p tsconfig.test.json --noEmit --listFiles lists the new pin once, and it has no errors.
  • pnpm exec eslint --no-inline-config over the three changed source files exited 0 with no output. The repo-wide pnpm lint is left to CI.
  • node scripts/check-cli-test-child-env.mjs --self-test passed 152 cases. Before the population edit, it failed on exactly that case.
  • Derived gate union at 8319dd69b: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack named 85 commands. All 85 exited 0, each exit code captured before any pipe. --ran reconciled them: 85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. The union includes check: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-loads and check:i18n-coverage. The derivation names origin/main as 3 commits ahead after the merge, with one changed derivation input, scripts/check-route-envelope.mjs. That edit moves packages/rest MODULES counts only, and this diff touches no packages/rest path. CI runs on the merge ref.

Acceptance notes


Generated by Claude Code

…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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 1 documentable anchor(s).

18 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761.

⛔ 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): node scripts/docs-audit/affected-docs.mjs --json b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9710cf1132c4f6ded4d630ebc4a332548b7540a3 — the merge of head 9fc58619b3bc55b4265b083bf8d3cd958d44dc93 into base b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs b7f29ddfe2390dfa5f2c5fc240be03fb6c4aa761 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 — ACCEPT · PR #24214 at 8319dd69b (#24192), landing after a merge of main

domain:cli seat 2 · marchtian · session session_01QyoWKK9RwUNxBxmevy9Neb · 2026-10-17T19:29Z · holder of claim 6238363854. The dev's report is comment 6239549172 on #24192, and the seat reviewed it against this PR, not the report's own account.

  1. Shape. Draft → main. The first line is Fixes #24192, the only closing keyword. Clause-②: no is line-initial, and the assignee is marchtian. 4 files, inside the claimed surface:

    check-governed-merges.mjs: not governed (380 changed lines).

  2. Premise, measured.

    • H1 held. The compile spawnSync handed its child the raw fd 1. Re-measured unfixed: 24 non-JSON lines (19 text, 5 blank) before the initialize answer. The card's 27 came from another fixture; same producer, same mechanism.
    • H2 was falsified, and the falsification is right. The seat read start.ts:150-162 on main: redirectStdoutToStderr() runs unconditionally at the top of run(), by os serve writes its banner and kernel logs to the stdout the stdio MCP transport owns #7915's contract ("everything on that fd from this process is a diagnostic, so it goes to stderr, unconditionally"). A plain os start's compile lines were the only lines on stdout. So the unconditional fix, stdio: ['ignore', 2, 'inherit'], aligns the compile child with that contract, and the order's conditional suggestion is correctly not taken.
    • Ablation: reverting the stdio arm reddens the pin ("stdout carries 24 line(s) …"), and the restore is proven by blob.
  3. Clause-②: no, read. Diagnostic output moves from stdout to stderr. That is CLI output routing, not a published accept set or export, and no line is dropped. The changeset states it. No contract-review face is touched.

  4. Check-runs on 8319dd69b are not the landing reading: the head moves with the merge below, and landing reads every check on the merged head. Local: 85 derived / 85 run (--ran exit 0); ESLint exit 0; the gate's --self-test passes 152 cases.

  5. Accepted deviations:

    • the route is unconditional, not conditional (H2 above);
    • one merge of main;
    • the 27-line figure re-measured as 24;
    • a verify-lock queue timeout, retried on the same slot.

    Changeset, read sentence by sentence: "What was wrong" matches H1. "With or without the stdio transport … Nothing is dropped" matches the unconditional 2 and the measured lines on stderr. "Unchanged …" holds: os compile run directly keeps its own stdout.

  6. Out of scope. The cli: os start with OS_MCP_STDIO_ENABLED spawns serve with 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 sentence ("the compile child's progress lines reach the same stdout") goes stale after this PR. It is comment drift and stays in Acceptance notes, not filed.

  7. Landing. PR fix(cli): os serve with the stdio MCP transport and no config argument answers an initialize written at spawn — its config positional no longer reads stdin #24212 lands first. Both PRs add one entry to check-cli-test-child-env.mjs's population and bump the same count line, so this PR then merges main (keeping both entries, count thirteen, self-test re-run). Once every check on the merged head is green or a roster skip, the seat flips ready and arms auto-merge. Fixes #24192 closes the card, and the seat strips pm:dispatched in the same act.


Generated by Claude Code

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
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 17, 2026 20:32
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 17, 2026 20:32
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit a8adf66 Oct 17, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24192-start-compile-stdout-purity branch October 17, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants