Repository navigation
fix(cli): os dev prints the ready banner whole, then the MCP connect block (#22410) - #22551
Conversation
…MCP block) The pin for #22410, committed ahead of the fix so it can be measured red against the old order first. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
…its banner The os dev parent prints its MCP connect block when the serve child's objectstack:listening message arrives, into the terminal the child prints its ready banner to. The child sent the message before the banner, so the two processes wrote one terminal at once and the block interleaved with the banner (#22410). publishBoundPort now drives state file, then banner, then IPC. The banner is written synchronously to a terminal, so the parent's block prints under a finished banner: a causal order, no timer. Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 28 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 0621351b18b6dce9fb2bd6b908a60715d2b1a463 && git checkout 0621351b18b6dce9fb2bd6b908a60715d2b1a463
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f782f1764410dcf7ac80108c3526eab4043032d7 c0f7c865dea6376696bee426cd050bce3deb422a && git checkout -B drift-repro f782f1764410dcf7ac80108c3526eab4043032d7 && git merge --no-ff c0f7c865dea6376696bee426cd050bce3deb422a
node scripts/docs-audit/affected-docs.mjs --json f782f1764410dcf7ac80108c3526eab4043032d7 |
Fixes #22410
Clause-②: no
The fix fixes the order of two blocks the CLI already prints. No accepted input, flag, key or export changes.
objectstack:listeningis the CLI's internal parent/child IPC.What changed
os devis two processes writing one terminal. The parent prints the MCP connect block when theservechild'sobjectstack:listeningIPC message arrives. The child used to send that message before it printed its ready banner, so the two printed at once.packages/cli/src/commands/serve.ts,publishBoundPort: the three bound-port channels now fire state file, then banner, then IPC (was state file, IPC, banner). TheBoundPortChannelsfield order and docs, the function's docblock and the call-site comment say why. The state file still comes first, so the earlier race (the file must exist before anything announces it) stays closed.keepStderrNonBlockingleaves a TTY alone), so every banner byte is in the terminal before the message is sent, and the parent prints only on receipt. No sleep, no timer.dev.tsis untouched. The child-side reorder alone measured clean (see the tables), so no single-write change to the parent's block was needed.{ type, port, url }is unchanged. Each place the child prints its banner is a call to this one function:publishBoundPorthas one call site, and boot diagnostics print inside the banner (printServerReadycallsprintBootDiagnostics). The otherprintBootDiagnosticscalls (theOS_MIGRATE_AND_EXITpath and the boot-failure catch) never announce at all.Measured before and after, under a real pty
Door:
os dev --ui -p PORTon the Build-with-Claude-Code tutorial project. The project is the bundledblanktemplate, given the scaffold's identity rewrite forsupport-desk, plus the page's four files extracted verbatim and their four barrel lines.os validateprintsData: 2 Objects 6 Fields,UI: 1 Apps 1 Views 1 Actions, the page's own output.@objectstack/*resolve by symlink to this worktree's built packages. Each boot ran underscript -qfecon a fresh.objectstack/anddist/, so it is a first boot that seeds the dev admin. The harness waited for the banner and the block, kept reading for 3 s, then typed a real Ctrl+C into the pty. A "foreign line" is any line between the block header and itsDisablerow that is not one of the block's rows.Before (dist built from base
d303b3e7a, theserve.jsorder read off the built file as IPC before banner):➜ Console:,➜ MCP:rows)6 of 7 boots printed the block above or inside the banner, 2 of 7 split the block, 0 of 7 put the credential line inside it. A setup boot before these seven printed 18 foreign lines between
SkillandConnect:➜ MCP:, the whole🔑 Dev admin: admin@objectos.ai / admin123section,Config/Mode/Driver/Tenancy/Plugins: 41 loadedandPress Ctrl+C to stop.After (dist rebuilt with this fix, the built file's order read as banner before IPC):
7 of 7 clean. A second after-run through the source entry (
bin/run-dev.jsunder tsx) was also 7 of 7 clean.The four PM hypotheses
publishBoundPortdrovewriteRuntimeState, thenannounceListening, thenprintBanner. Boot diagnostics are not printed separately on the success path:printServerReadyprints them inside the banner, beforePress Ctrl+C to stop. TheprintBootDiagnosticscall in the card was in the boot-failurecatch, which never announces.console.logcalls; the banner goes to the child's stderr (console.error). In the setup boot the parent printed three block rows, the child printed about twenty lines, then the parent printed the last two. So eachconsole.logis its own write that another writer can land between. With the message sent last, no child line printed during the block in 14 of 14 after-boots, so a single atomic write of the block was not needed. A child line logged in the instant after the banner could still land inside the block; nothing in these boots did.objectstack:listeningare thedev.tshandler (the bound-port line, the MCP block, the restart line), the unit tests inserve-bound-port-publication.test.tsanddev-seed-settled-forward.test.ts, andserve-publishes-bound-port.e2e.test.ts, which waits for both the banner and the message.ServeRestartCoordinatorreads no IPC.start.tsmentions it only in a comment, andcontent/docs/deployment/cli.mdxdocuments it as "the HTTP server is bound", which stays true. The message arrives one banner print later. No split into two messages.os dev --uisession (watch on), 3 restarts were triggered by editingsrc/objects/ticket.object.ts. Each✓ server restarted — the new build is liveline printed right after that restart'sPress Ctrl+C to stop(3 of 3), and the MCP block printed once, on the first boot only. The restart leg was not run against base.Pins
packages/cli/test/dev-boot-output-order.integration.test.ts(new, integration tier). It runs the builtos dev --no-watchon a minimal fixture underscript -qfec, 5 boots, each on a fresh database file so the dev admin is seeded. For every boot it asserts three things: the block's five rows are contiguous; theDev adminline is present and outside the block; the block starts after the banner'sPress Ctrl+C to stop. A control asserts that every boot printed the block, the banner tail and the credential line. Without util-linuxscriptit fails rather than skips, aslogin-json-ndjson.e2e.test.tsdoes.4 of 5 boots printed the block inside or above the banner. In that run 0 of 5 boots split the block and 0 of 5 put the credential inside, so the red comes from the order assertion.Tests 1 failed | 2 passed (3), 76 s.Tests 3 passed (3), 75 s.packages/cli/test/serve-bound-port-publish-order.test.ts(updated). The deterministic in-process pin of the sequence now expects['state-file', 'banner', 'ipc'], and its header records the second constraint. Ablation:scripts/ablation-replace.mjsmutatedserve.tsback to IPC before banner (anchor 1 to 0, blobab5982d524afto727152c21e85). The test went red, 2 failed and 2 passed:expected [ 'state-file', 'ipc', 'banner' ] to deeply equal [ 'state-file', 'banner', 'ipc' ]. The restore was proven: blob equals HEADab5982d524afandgit diff HEADis empty.src/commands/dev-mcp-connect-hint-origin.test.ts(the block still prints, with the right endpoint origin) is in the unit layer, which passed.Tests and gates (at
c0f7c865d)pnpm --filter @objectstack/cli typecheck: exit 0.check:test-typecheckpassed with its ledger unchanged at 3 files and 28 errors.tsc -p tsconfig.test.json --listFileslists both test files (1 hit each); its 28 errors are the ledgered ones in 3 other files.vitest run --project unit):Test Files 275 passed (275),Tests 4057 passed (4057).vitest run --project integration):Test Files 101 passed (101),Tests 934 passed | 2 skipped (936)(this run includes the new pin). Run under the verify lock, which it held for 44m47s on a shared box.serve-publishes-bound-port.e2e.test.ts(nightly tier, run withOS_TEST_TIERS=nightly):Tests 6 passed (6). It is the IPC message's ordinary consumer and waits for both the banner and the message.dispatch-gates.mjs --commandsderives for this diff all exited 0, each run once from the worktree root.--ranreconciliation:67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsandcheck:i18n-coveragefirst answered PREREQUISITE NOT MET (exit 3) for 9 packages this diff does not touch. They were re-run green after a build of those packages, all 58 tasks restored from the turbo cache..tsfiles is in ESLint's population:--format jsonlists all 3 with 0 errors, 0 warnings and no "File ignored" message. The changeset is outside it ("File ignored because no matching configuration was supplied").eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules) and reads only its two baseline JSON files, which this diff leaves alone. So the diff cannot move a verdict on any untouched file. The fullpnpm lintis CI's.git grep -E 'connect a coding agent|Press Ctrl\+C to stop|objectstack:listening|Server is ready|printMcpConnectHint|printServerReady|Dev admin' -- packages/qa/exits 1 with no hits. The same tree's dogfood tests do containdescribe(.Acceptance notes
mainthe measurement made two changes to the scratch copy. It stampedengines: { protocol: '^18' }, because the template still carries^17while the runtime is protocol 18; the release'ssync-template-versionspass stamps that field. It also setOS_ALLOW_CONSOLE_DRIFT=1, because the Console SPA was the published@objectstack/console@17.7.0dist, extracted into the gitignoredpackages/console/distand built from a different objectui sha than the pin. Neither touches the order being measured.keepStderrNonBlocking. There the message can still overtake banner bytes that are waiting in the queue. That case is a program reading the output, not a terminal; this change leaves it alone.Generated by Claude Code