Skip to content

Commit 161aee1

Browse files
fix(cli): a serve boot that binds no HTTP server announces no address, and MCP: prints only when its route is mounted (#23947)
Fixes #23743 Clause-②: no ## What this changes This is the remainder of #23623 / PR #23741. That PR gated the ready banner's address rows on a composed HTTP server. It left two things as they were, and this PR fixes both. **1. The machine channels on a boot that binds no HTTP server.** `os serve --no-server`, with no transport composed, sent `{ type: 'objectstack:listening', port, url }` and wrote `runtime.ENV.PROJECT.json` with the same `port` and `url`. Nothing listened on that port. The docs row (`content/docs/deployment/cli.mdx`) says: "`objectstack:listening` | `os serve` | The HTTP server is bound." - **`packages/cli/src/commands/serve.ts`, the publish site.** `publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner), boundProtocol)` now runs only when `httpServer === 'composed'`. Otherwise only the banner prints. `httpServer` is the `resolveHttpServerComposition(kernel, flags.server)` value the banner's address rows already read. One predicate, read once, decides all three channels, so the banner, the IPC message and the state file cannot disagree about whether there is an address. `publishBoundPort`, its three channels and their order are unchanged. `resolveBoundPort` is unchanged too: it still falls back to the requested port, and the publish site no longer publishes that fallback. - **`objectstack:seed-settled` on that boot: still sent, and it is the only message.** Consumers measured in this repository: the `os dev` parent (`dev.ts`, read, not edited) relays it and never spawns a `--no-server` child, because `os dev` has no `server` flag. The docs example waits for `seed-settled` alone. `SeedSettledMessage`'s own rule is that a waiting parent must never hang. A kernel-only boot still seeds, so dropping the message would hang exactly the parent that rule protects. Both docs rows are updated together. The listening row now says the message is not sent by a boot that binds no HTTP server. The seed-settled row keeps "always after `objectstack:listening`" and adds that on such a boot it is still sent and is the only message. - **The runtime state file on that boot: not written.** Readers measured in this repository: `scripts/publish-smoke.sh` (reads `.port`, then curls `/api/v1/health`; it spawns `objectstack dev`, which never runs `--no-server`), plus the CLI's own tests (`serve-bound-port-publication.test.ts`, `test/serve-bound-port-publish-order.test.ts`, `test/serve-publishes-bound-port.e2e.test.ts`, `test/serve-runtime-state-project-key.test.ts`, `test/helpers/runtime-state-child.ts`). No docs page states a contract for the file. I chose "not written" over "written with no URL or an unbound marker" for three reasons. The file's whole payload answers "is my server running, and where?", and `runtimeStateFileName`'s docblock already calls a missing file "a loud, correct answer". An unbound record would add a second payload shape that every reader would have to handle; `publish-smoke.sh`'s `jq '.port | numbers'` would read it as "no port yet" and probe until its timeout. And the payload stays the shape the project-key ruling (`serve.ts`, `projectStateKey`) chose not to widen. - `os start` never passes `--no-server`, and `os dev` never does either, so every in-repo consumer of these two channels sees the same bytes as before. **2. The banner's `MCP:` row under a config-mounted server.** Under `--no-server`, with a config that mounts its own `HonoServerPlugin`, the banner printed `MCP: http://localhost:PORT/api/v1/mcp` and its connect hint, but `GET /api/v1/mcp` answered 404. The plugin that mounts that route is the runtime dispatcher, `createDispatcherPlugin`, and `serve` composes it only inside `if (flags.server)`. - **`serve.ts`: `readComposedMcpRouteOwner(kernel)`** asks the booted kernel `hasPlugin('com.objectstack.runtime.dispatcher')`. This is a kernel read like `readComposedHttpServer`, so a config that mounts its own dispatcher is answered as well. The dispatcher registers no service; it only consumes `http.server`. So the kernel's plugin registry is the read, the same one `HonoServerPlugin` already asks before it warns that no data or discovery API is mounted. The banner gets `mcpEnabled: isMcpServerEnabled() && mcpRouteMounted`, where `mcpRouteMounted = httpServer === 'composed' && readComposedMcpRouteOwner(kernel)`. If the kernel cannot answer, the result is `false`. - **`packages/cli/src/utils/format.ts`**: doc comment only, on the `mcpEnabled` field the `MCP:` row reads, saying what `serve` now passes. `printServerReady` is unchanged. ⛔ Not changed: exports of `@objectstack/cli`, flags, exit codes, process lifetime, `packages/spec`, `packages/runtime`, `dev.ts`. ## Measured at the door Each boot runs `node packages/cli/bin/run.js serve objectstack.config.ts --port PORT ARGS` on the built CLI, spawned with an `ipc` channel. `NODE_ENV` is unset except under `--dev`, and `OS_CLOUD_URL=off`. The fixture is a throwaway host config with one object and one plugin instance. Before is `7a6ba03109`, after is `cccb00ea51`. "State file" means `OS_HOME/runtime.env_local.PROJECT.json` as read when the listening message arrived. | Shape | IPC before | IPC after | State file before / after | Banner address rows after | Probe (before and after) | |---|---|---|---|---|---| | production `--no-server --preset minimal` (the card's command) | `objectstack:listening` with `url: 'http://localhost:38393'`, then `seed-settled` | `seed-settled` only | written with that `url` / none | `No HTTP server: …` line (unchanged from #23623) | `ECONNREFUSED` on `/`, `/api/v1/health`, `/api/v1/mcp` | | `--dev --no-server` | listening, then `seed-settled` | `seed-settled` only | written / none | the same line | `ECONNREFUSED` | | production default (control) | listening with the bound port, then `seed-settled` | unchanged | written / written, same `port` and `url` | `API:`, `MCP:` and the hint (unchanged) | `/api/v1/health` 200, `/api/v1/mcp` 401 | | `--no-server --preset minimal` + config-mounted `HonoServerPlugin` | listening, then `seed-settled` | unchanged: a transport is bound | written / written | `API:` only; before: `API:`, `MCP:` and the hint | `/api/v1/mcp` 404, `/api/v1/data/fx_task` 401 | | default preset + config-mounted `HonoServerPlugin`, `--server` (control) | not measured before | listening, then `seed-settled` | — / written | `API:`, `MCP:` and the hint | `/api/v1/health` 200, `/api/v1/mcp` 401 | ## Tests - **`src/commands/serve-no-transport-announcements.test.ts`** (new, unit, 10 cases). - `readComposedMcpRouteOwner` against a real `ObjectKernel`: `true` with `createDispatcherPlugin()` registered, and `false` with a transport-only composition. - `MCP_ROUTE_OWNER_PLUGIN` is read off `createDispatcherPlugin().name`, so a rename of the dispatcher reds here instead of silently dropping the row. - It asks for exactly that name, and answers `false` for no kernel, no `hasPlugin`, a throwing `hasPlugin`, and a non-boolean answer. - The `run()` wiring, with comments masked by `scripts/js-comment-mask.mjs`: the publish call sits in the `httpServer === 'composed'` arm with `printBanner()` alone in the other arm; `httpServer` is resolved once and is what the banner reads; the `mcpEnabled` / `mcpRouteMounted` spelling is pinned; the settle latch is released once, after the gated publication on both arms. An anti-vacuity case checks that the mask blanked comments. - **`src/commands/serve-no-transport-announcements.integration.test.ts`** (new). `vitest-tiers.ts` puts it in the integration tier. It runs three boots of the built CLI, each with an `ipc` channel and an `OS_HOME` directory watched from before the spawn. - Production `--no-server`: no `objectstack:listening`; the messages are exactly `['objectstack:seed-settled']`; and no `runtime.*.json` was ever created. The watch matters: this shape exits on its own and its exit handler deletes the file, so a listing taken afterwards cannot tell "never written" from "cleaned up". - Control, default boot: listening and then seed-settled; the message equals `{ port, url }` of the `API:` row; the state file present at that moment carries the same `port` and `url`; an `MCP:` row prints; health answers 200 and `/api/v1/mcp` answers something other than 404. - Config-mounted Hono under `--no-server`: no `MCP:` row and no connect hint; `/api/v1/mcp` answers 404 (the reason the row must not print); the IPC message and the state file both carry the `API:` address. - The three no-transport assertions are `expect.soft`, so each channel reports on its own. The file declares `// test-budget: 30`: the three local runs took 19.4 s, 18.9 s and 20.9 s on a shared container; 20.9 × 1.25, rounded up. - **Unit tier**, `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 --shard=K/4`. It is sharded because one unsharded run does not fit the container's foreground cap. Shards 1 to 3 ran on `23b3033ffc` (this branch merged with `origin/main`); shard 4 also ran there, and its one failure was fixed in `cccb00ea51`. - shard 1: 76 files, 1418 tests passed. - shard 2: 76 files, 1099 passed and 3 failed. All 3 were `Test timed out in 5000ms` while my unlocked gate battery shared the cores. The two files (`src/utils/boot-preparation-parity.test.ts`, `test/hook-timeout-override-refusal.test.ts`) re-run alone: 2 files, 41 tests passed. - shard 3: 76 files, 918 passed. - shard 4: 75 files, 1071 passed and 1 failed. `serve-observability-registration.test.ts` finds the transport by the first raw-source occurrence of the `HonoServerPlugin` constructor call, and my new docblock spelled that call above the registration. `cccb00ea51` rewords the docblock. - **On the final head `cccb00ea51`**, after rebuilding the CLI: - six unit files (`serve-observability-registration`, the new unit file, `serve-bound-port-publication`, `serve-http-server-composition`, `format.server-ready-no-http-server`, `test/serve-bound-port-publish-order`): 6 files, 82 tests passed; - integration, scoped (the diff adds an integration file and edits `serve.ts`): the new file, `serve-no-server-banner.integration`, `test/serve-no-storage-boot-guard.integration`, `test/dev-boot-output-order.integration`: 4 files, 15 tests passed; - `OS_TEST_TIERS=nightly`, `test/serve-publishes-bound-port.e2e.test.ts` (the publication's own end-to-end pin, which runs nightly and not on this PR): 1 file, 6 tests passed; - the rest of the integration tier is declared to CI. ## Ablation The fix was committed first. Each leg went through `node scripts/ablation-replace.mjs` in wrap mode (it restores on exit, and a second `trap` restores from `HEAD`). Then the CLI was rebuilt, `node scripts/ablation-dist-preflight.mjs @objectstack/cli MARKER` was run, and the two new files were run. - **The publish gate.** The anchor `if (httpServer === 'composed') {` was replaced with an always-true condition carrying the marker `ablation-23743-gate`. - On disk: anchor x1 to x0, marker x0 to x1, blob `8de5619d0a58` to `f04d083f4255`. The marker was present in 1 built file (`dist/commands/serve.js`). - Result: `Tests 3 failed | 10 passed (13)`. Integration case 1 reported all three soft assertions: "objectstack:listening was sent by a boot that bound no HTTP server", "seed-settled is not the boot's one message", "a runtime state file was written by a boot that bound no HTTP server". So the watched-directory read does see a file that the exit handler later deletes. The two wiring source pins also failed. Both controls passed. - Direction: turned red, as predicted. - Restore: blob back to `8de5619d0a58`, which is HEAD; `git diff HEAD` empty; `git status --porcelain` empty. After a rebuild, `--absent` found the marker in none of the 640 built files. `Tests 13 passed (13)`. - **The `MCP:` gate.** `mcpEnabled: isMcpServerEnabled() && mcpRouteMounted,` was replaced with an always-true right operand carrying `ablation-23743-mcp`. - On disk: anchor x1 to x0, blob `8de5619d0a58` to `630307660e27`; the marker was present in 1 built file. - Result: `Tests 2 failed | 11 passed (13)`: "an MCP: row printed for an unmounted route" (Hono case) and the `mcpEnabled` source pin. The no-transport case and the default control passed. - Direction: turned red, as predicted. - Restore: proven the same way; `--absent` in all 640 files; `Tests 13 passed (13)`. The ablations ran against the `serve.ts` blob `8de5619d0a58`, which is unchanged from `99c982b4cd` through `23b3033ffc`. `cccb00ea51` changes only a docblock in it. ## Gates All on the final head `cccb00ea51`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derives 96 commands. Its stderr names the tree as `objectstack-ai/objectstack` at `cccb00ea51`, with 6 paths changed against merge base `944f03850`. Each command was run as `cmd > log 2>&1; ec=$?`, and all 96 exited 0. - On the first pass (on `23b3033ffc`), three gates exited 3 with `PREREQUISITE NOT MET`: `spec check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage`. The packages they read had no `dist/`: studio, client-react, connector-slack, the example apps and others outside the CLI's closure. After `turbo run build` of those (75 tasks, exit 0), all three exit 0 on `cccb00ea51`. - `--ran ran.list` prints: `96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED (a DERIVED zero — all 96 recorded an exit code and none of them is 3)`. - `pnpm --filter @objectstack/cli typecheck` exits 0: `tsc --noEmit`, then `check:test-typecheck: OK`. `tsc -p tsconfig.json --noEmit --listFilesOnly` lists both new files (2 of 2). - Lint lane: `pnpm exec eslint --no-inline-config` over the four changed `.ts` files exits 0 with no output. The repo-wide `pnpm lint` is CI's. - Not run locally, by rule: `check:dev-prereqs` and `check:skill-frame-freshness` (no `packages/spec` build or frame file is touched), and `check-platform-checklist.mjs --changed-from` (no exported symbol is renamed or removed; two are added). - CI check-runs: not read yet; they start when this PR opens. ## Acceptance notes - **Process lifetime (#23744, not touched here).** Measured on base `7a6ba03109` and after `cccb00ea51`: production `--no-server --preset minimal` prints its banner about 5.1 s to 5.8 s after spawn, and then exits 0 by itself within milliseconds, with or without an `ipc` channel. `seed-settled` arrives before that exit in every run measured. `--dev --no-server` stays up until stopped. This PR changes neither. - **A stale state file is no longer overwritten by a no-transport boot.** If an earlier boot of the same project died without running its exit handler (SIGKILL, a crash), its `runtime.ENV.PROJECT.json` survives. Before, a later `--no-server` boot overwrote it with its own record, carrying a URL nothing listened on. Now that boot writes nothing, so the stale record stays, naming the dead `pid`. A reader that checks `pid` liveness is unaffected. The file is documented as best-effort and was never cleaned up on a hard kill. Observation only; no carrier. - **`start.ts` comment.** A comment in `packages/cli/src/commands/start.ts` says `serve` publishes `objectstack:listening` "unconditionally". It is now conditional on a composed transport. `os start` never passes `--no-server`, so for its child the sentence still holds in practice. It is outside this card's file surface and is left as is. Observation only. - **Dispatcher prefix.** A config that mounts its own `createDispatcherPlugin({ prefix })` with a non-default prefix gets an `MCP:` row naming `/api/v1/mcp` whatever the prefix. `serve`'s own dispatcher always uses the default. No in-repo producer of that shape was found, and the behaviour is unchanged by this PR. Observation only. - **`readComposedMcpRouteOwner` answers "is the route's owner composed", not "is the route mounted".** The dispatcher mounts `/api/v1/mcp` unconditionally in its `start()` once an `http.server` is registered, and the row also requires a composed transport. So with today's dispatcher the two are the same. No live route table exists on `IHttpServer` to read instead. --- _Generated by [Claude Code](https://claude.ai/code/session_01PFB9ZGRWYvjhTXsd8i6KBp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61e5ee9 commit 161aee1

6 files changed

Lines changed: 655 additions & 12 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): an `os serve` boot that binds no HTTP server sends no `objectstack:listening` message and writes no runtime state file, and the banner's `MCP:` row prints only when the MCP route is mounted
6+
7+
Clause-②: no
8+
9+
- **Before.** `os serve --no-server`, with no transport composed, sent `{ type: 'objectstack:listening', port, url: 'http://localhost:PORT' }` over an `ipc` channel and wrote `runtime.<environment>.<project>.json` with the same `port` and `url`. Nothing listened on that port: a `GET` to it was refused. The docs define `objectstack:listening` as "The HTTP server is bound".
10+
- **After, for a supervisor of a boot that binds no HTTP server** (`--no-server` with no config-mounted transport, or a transport plugin that did not load). It receives no `objectstack:listening` message. It still receives `objectstack:seed-settled`, which is then the boot's only message, so a parent waiting for the seed to settle does not hang. No `runtime.<environment>.<project>.json` is written, so "is my server running, and where?" gets no answer naming an address. The file's payload, where it is written, is unchanged.
11+
- **After, with an HTTP server composed.** Unchanged: the state file is written first, then the banner prints, then `objectstack:listening` is sent with the port actually bound, and `objectstack:seed-settled` follows it. That includes a `--no-server` boot whose config mounts its own `HonoServerPlugin`. The banner, the IPC message and the state file read the same check, a registered `http.server` service on the booted kernel, so they agree on whether there is an address.
12+
- **The banner's `MCP:` row.** It and its "connect an AI client" hint now also require the plugin that mounts `/api/v1/mcp`, the runtime dispatcher, to be registered on the kernel. `os serve` composes the dispatcher only under `--server`. So a `--no-server` boot whose config mounts its own `HonoServerPlugin` prints `API:` but no longer prints an `MCP:` address, which answered 404 there.

‎content/docs/deployment/cli.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,8 @@ output at all. Spawn with an `ipc` channel and wait for a message:
286286

287287
| Message | Sent by | Means |
288288
|---|---|---|
289-
| `objectstack:listening` | `os serve` | The HTTP server is bound. Carries `{ port, url }` — the port actually bound, which in dev may differ from the one requested. |
290-
| `objectstack:seed-settled` | `os serve` | Nothing is still seeding. Carries `{ ok, suppressed, sources }`. Sent once per boot, always **after** `objectstack:listening`. |
289+
| `objectstack:listening` | `os serve` | The HTTP server is bound. Carries `{ port, url }` — the port actually bound, which in dev may differ from the one requested. Not sent by a boot that binds no HTTP server (`--no-server` with no transport composed). |
290+
| `objectstack:seed-settled` | `os serve` | Nothing is still seeding. Carries `{ ok, suppressed, sources }`. Sent once per boot, always **after** `objectstack:listening`; on a boot that binds no HTTP server it is still sent, and is the only message. |
291291

292292
```js
293293
import { spawn } from 'node:child_process';

0 commit comments

Comments
 (0)