Skip to content

fix(cli): os dev prints the ready banner whole, then the MCP connect block (#22410) - #22551

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22410-dev-banner-order
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-22410-dev-banner-order

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:listening is the CLI's internal parent/child IPC.

What changed

os dev is two processes writing one terminal. The parent prints the MCP connect block when the serve child's objectstack:listening IPC 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). The BoundPortChannels field 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.
  • The order is causal, not timed. The banner is written synchronously to a terminal (POSIX TTY stdio is synchronous, and keepStderrNonBlocking leaves 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.ts is untouched. The child-side reorder alone measured clean (see the tables), so no single-write change to the parent's block was needed.
  • The message shape { type, port, url } is unchanged. Each place the child prints its banner is a call to this one function: publishBoundPort has one call site, and boot diagnostics print inside the banner (printServerReady calls printBootDiagnostics). The other printBootDiagnostics calls (the OS_MIGRATE_AND_EXIT path and the boot-failure catch) never announce at all.

Measured before and after, under a real pty

Door: os dev --ui -p PORT on the Build-with-Claude-Code tutorial project. The project is the bundled blank template, given the scaffold's identity rewrite for support-desk, plus the page's four files extracted verbatim and their four barrel lines. os validate prints Data: 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 under script -qfec on a fresh .objectstack/ and dist/, 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 its Disable row that is not one of the block's rows.

Before (dist built from base d303b3e7a, the serve.js order read off the built file as IPC before banner):

boot foreign lines inside the block credential inside the block block position parent rows inside the banner
1 2 (➜ Console:, ➜ MCP: rows) no inside the banner 5
2 0 no above the banner 0
3 0 no after the banner 0
4 0 no inside the banner 5
5 0 no above the banner 0
6 0 no inside the banner 5
7 1 (a blank banner line) no inside the banner 5

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 Skill and Connect: ➜ MCP:, the whole 🔑 Dev admin: admin@objectos.ai / admin123 section, Config / Mode / Driver / Tenancy / Plugins: 41 loaded and Press Ctrl+C to stop.

After (dist rebuilt with this fix, the built file's order read as banner before IPC):

boot foreign lines inside the block credential inside the block block position parent rows inside the banner
1–7 0 no after the banner 0

7 of 7 clean. A second after-run through the source entry (bin/run-dev.js under tsx) was also 7 of 7 clean.

The four PM hypotheses

  • H1 (the child): held. At base, publishBoundPort drove writeRuntimeState, then announceListening, then printBanner. Boot diagnostics are not printed separately on the success path: printServerReady prints them inside the banner, before Press Ctrl+C to stop. The printBootDiagnostics call in the card was in the boot-failure catch, which never announces.
  • H2 (the parent): held. The block goes to the parent's stdout in six console.log calls; 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 each console.log is 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.
  • H3 (other readers): no reader needs the port earlier. The readers of objectstack:listening are the dev.ts handler (the bound-port line, the MCP block, the restart line), the unit tests in serve-bound-port-publication.test.ts and dev-seed-settled-forward.test.ts, and serve-publishes-bound-port.e2e.test.ts, which waits for both the banner and the message. ServeRestartCoordinator reads no IPC. start.ts mentions it only in a comment, and content/docs/deployment/cli.mdx documents it as "the HTTP server is bound", which stays true. The message arrives one banner print later. No split into two messages.
  • H4 (restart): holds after the fix. In one os dev --ui session (watch on), 3 restarts were triggered by editing src/objects/ticket.object.ts. Each ✓ server restarted — the new build is live line printed right after that restart's Press 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 built os dev --no-watch on a minimal fixture under script -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; the Dev admin line is present and outside the block; the block starts after the banner's Press Ctrl+C to stop. A control asserts that every boot printed the block, the banner tail and the credential line. Without util-linux script it fails rather than skips, as login-json-ndjson.e2e.test.ts does.
    • Against a dist built from base: red, 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.
    • Against the rebuilt fix: 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.mjs mutated serve.ts back to IPC before banner (anchor 1 to 0, blob ab5982d524af to 727152c21e85). 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 HEAD ab5982d524af and git diff HEAD is empty.
  • Control: 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-typecheck passed with its ledger unchanged at 3 files and 28 errors. tsc -p tsconfig.test.json --listFiles lists both test files (1 hit each); its 28 errors are the ledgered ones in 3 other files.
  • cli unit layer (vitest run --project unit): Test Files 275 passed (275), Tests 4057 passed (4057).
  • cli integration layer (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 with OS_TEST_TIERS=nightly): Tests 6 passed (6). It is the IPC message's ordinary consumer and waits for both the banner and the message.
  • The 67 commands dispatch-gates.mjs --commands derives for this diff all exited 0, each run once from the worktree root. --ran reconciliation: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. check:dual-build-cjs-loads and check:i18n-coverage first 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.
  • Lint, by a narrowing rather than a full run. Each of the 3 changed .ts files is in ESLint's population: --format json lists 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.mjs enables no type-aware linting (no parserOptions.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 full pnpm lint is CI's.
  • Dogfood is declared to CI. 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 contain describe(.

Acceptance notes

  • To boot the tutorial project on today's main the measurement made two changes to the scratch copy. It stamped engines: { protocol: '^18' }, because the template still carries ^17 while the runtime is protocol 18; the release's sync-template-versions pass stamps that field. It also set OS_ALLOW_CONSOLE_DRIFT=1, because the Console SPA was the published @objectstack/console@17.7.0 dist, extracted into the gitignored packages/console/dist and built from a different objectui sha than the pin. Neither touches the order being measured.
  • The before-table counts 0 of 7 credential lines inside the block, where the card counted 1 of 7. The setup boot did put it inside, and so did the card's run. The red against base is measured on the order: in 6 of 7 boots the block was above or inside the banner.
  • A non-TTY stderr (a pipe whose reader has stalled) is written asynchronously by 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

claude added 3 commits October 9, 2026 20:43
…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • 1 anchor(s) matched too much of the corpus to be a work list: os serve (command, 31 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 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 f782f1764410dcf7ac80108c3526eab4043032d7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 0621351b18b6dce9fb2bd6b908a60715d2b1a463 — the merge of head c0f7c865dea6376696bee426cd050bce3deb422a into base f782f1764410dcf7ac80108c3526eab4043032d7, 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 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

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 23:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 23:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit 4638625 Oct 9, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22410-dev-banner-order branch October 9, 2026 23:46
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

1 participant