Skip to content

Commit ed5a1e7

Browse files
claude[bot]claude
andauthored
feat(cli): announce seed settlement on serve's ipc channel and forward it from os dev (#17892)
Fixes #17329 Clause-②: yes `os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent when one holds the channel. Implements decision batch #118 item 5 (**D1**) as ruled at [`5642795097`](#17329 (comment)), plus its banner rider. ⭐ **The producer already existed.** `@objectstack/runtime` declares every seed source and settles it the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This PR is **the hop outward** — it registers no service, mutates no tally, and changes nothing in `packages/runtime` or `packages/spec`. It subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. ## Every site was re-derived from its SYMBOL Every line number on the card was stale twice over, so nothing was carried forward. Re-read on `origin/main` at `272c04b46`: | thing | site today | |---|---| | the existing ipc message | `publishBoundPort` / `runtimeBoundPortChannels` in `packages/cli/src/commands/serve.ts` | | the banner | `printServerReady` in `packages/cli/src/utils/format.ts`, called through a thunk from `serve.ts` | | `os dev`'s role | spawns `serve --dev` over `stdio: ['inherit','inherit','inherit','ipc']`; consumes the listening message, emits no banner | | the producer | `emitSeedSettled` in `packages/runtime/src/app-plugin.ts`, `declareSeedSource` in `packages/runtime/src/seed-settlement.ts` — **read only, both untouched** | ## ⭐ Ruled item 3, MEASURED on a real boot The gate: multi-tenant replay and `skipSeedData` report `pending > 0` for the whole boot, so a consumer waiting on the new message must not hang forever there. ⛔ **The predicate is `inFlight === 0`, not `pending === 0`.** `suppress()` moves a source out of the in-flight tally and records why, so both modes reach `inFlight === 0` inside Phase 2 `start()` while `pending` stays above zero forever. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this card exists to end, one level up. **Measured**, `os dev` under `OS_TENANCY_POSTURE=group` with the org runtime declared by the host app, a real ipc parent recording with the clock: ```text [child] Tenancy: group [child] Seeds: not run this boot (multi-tenant-replay) [PARENT +9.954s] BANNER SEEN [PARENT +9.955s] IPC {"type":"objectstack:seed-settled","ok":true, "suppressed":["multi-tenant-replay"],"sources":[]} ``` The message arrived 1 ms after the banner on a boot whose `pending` never reaches zero. **The consumer does not hang.** Reaching that boot needed three refusals satisfied in order — the host app must *declare* `@objectstack/organizations` (#4719: merely reachable is rejected), `OS_PLATFORM_OWNER_EMAIL`, and `OS_AUTH_MEMBERSHIP_POLICY`. The app-manifest declaration was made **temporarily for the measurement and reverted**; `package.json` and `pnpm-lock.yaml` were both restored to their `HEAD` blob hashes with a clean whole-tree `git status`, and this PR carries neither file. ⚠️ **`skip-seed-data` was NOT reached on a real boot, and that is a code-path fact rather than a gap.** It is set only by `createStandaloneStack` from `os migrate`'s planning path (`packages/cli/src/utils/schema-migrate.ts`); `serve` never sets it, so no `os serve` / `os dev` boot can enter that branch. It is pinned structurally against the contract's own `SeedSettlementSnapshot`, with the producer-side pin in `packages/runtime/src/app-plugin.seed.test.ts` green in the same session as the control (14/14). ## The other two clocks, also measured | boot | banner | settle message | |---|---|---| | ordinary in-budget | `+13.728s` | `+13.729s`, `ok: true`, 132 rows | | over budget (`OS_INLINE_SEED_BUDGET_MS=1`) | `+8.938s` | `+8.940s` — the budget WARN fired, the continuation still finished first | | seed with failures (memory driver) | `+8.533s` | `+8.535s`, **`ok: false`**, `110 ok / 22 errors` | ⚠️ **Bound on the measurement, stated rather than smoothed over.** The sub-case where the continuation is *still writing when the banner prints* did **not** reproduce in 8 live attempts across the `sqlite`, `sqlite-wasm` and `memory` drivers: on this container the seed completes during the remaining seconds of plugin startup, so the banner always lost the race in the reachable direction. That state was measured on this card earlier (`{"pending":1,"inFlight":1}` read at banner time, [`5636189867`](#17329 (comment))); here it is driven as a unit with an ablation instead of waited for. ## Ordering, and why this is not a fourth bound-port channel The settle is latched and released **after** `publishBoundPort` has driven its three channels, so a parent that waits for `objectstack:listening` and only then listens for the settle cannot miss one that happened during `runtime.start()`. ⛔ It is deliberately not folded into `publishBoundPort`: those three are one ordered publication of ONE number, and this is a different fact on a different clock that frequently has not happened yet. The `publishBoundPort` call-site pin still reads exactly two mentions in code. ## Ablation — three legs, each restored by blob hash Run under the shared verify lock; every mutation proven on disk (anchor uniqueness before, old/new counts and a moved blob hash after) before its verdict was read. | leg | mutation | result | |---|---|---| | `pending` predicate | `inFlight === 0` → `pending === 0` | **3 failed / 42 passed** — both suppressed-mode pins and the discriminating pin | | no latch | drop the `released` gate | **1 failed / 44 passed** — the ordering pin | | no `kernel:ready` hook | remove the suppressed boot's only leg | **1 failed / 44 passed** — the wiring pin | Each leg restored with `git checkout HEAD -- PATH`, restored blob `8fa02a659e8cae77207e0e326cbb51ad69e2ceeb` matching HEAD in all three, and a clean whole-tree `git status` at the end. ## Rider 2 — the banner no longer omits seeding `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was **absent** and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing` with a line saying seeding continues in the background; suppressed sources are named instead (`not run this boot (multi-tenant-replay)`, as measured above). The ablation for this half is in the test file: the same options without the reading reproduce the zero-seed-row transcript. ## Verification - **93 derived gate families** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, reconciled against the run list) — **all green**. Two first returned a prerequisite rather than a verdict (`check:skill-examples` exit 1, `check:dual-build-cjs-loads` **exit 3 = PREREQUISITE NOT MET**, both naming unbuilt packages); after building `@objectstack/client-react` and `@objectstack/organizations` both re-ran **exit 0**. Neither was read as a pass or a finding in between. - **`pnpm lint`, the FULL union** (`eslint . --no-inline-config`, ⛔ not narrowed) — exit 0 captured before any pipe; **6672 files** counted from eslint's own `--format json`; 0 errors, 0 warnings. Positive control: all 6 changed source files appear by name in eslint's own output. - `pnpm --filter @objectstack/cli exec vitest run --project unit` — **204 files / 2935 tests passed**. - `pnpm --filter @objectstack/cli exec vitest run --project integration` — **45 files / 397 tests passed** (run locally rather than declared to CI, because this diff touches the kernel startup path). - `pnpm --filter @objectstack/cli typecheck` — exit 0, including `check:test-typecheck`. - `pnpm --filter '@objectstack/cli^...' build` — exit 0. - Producer-side control: `pnpm --filter @objectstack/runtime exec vitest run src/app-plugin.seed.test.ts` — 14/14, pinning the exact `suppressed` snapshots this message consumes. All heavy runs serialised through `scripts/pm/os-verify-lock.sh`; every verdict read from the wrapper's own `VERDICT command-exit` line, never a bare exit status. ## Acceptance notes - ⚠️ **`os dev` and `os serve` are not symmetric, and the docs now say so.** `os dev` consumes `objectstack:listening` itself — it is how the `↪ server bound to port` line and the MCP connect hint learn the real port — and relays only the settle message. Measured: an ipc parent of `os dev` receives exactly one message. Widening that was not ruled and is not attempted here; a consumer that needs both spawns `os serve`. Noted, not filed — no open PR or queued card touches this surface. - The announcer plugin is deliberately **not** `trackPlugin`ed: that list feeds the banner's `Plugins:` count and name row, and an internal subscriber does not belong on a published banner. - `packages/spec` and `packages/runtime` were read and never edited, as ordered. Nothing in the design needed either changed. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- _Generated by [Claude Code](https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a61ae59 commit ed5a1e7

8 files changed

Lines changed: 1045 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
`os serve` now announces **`objectstack:seed-settled`** on its existing ipc channel when this boot's seeding has come to rest, and `os dev` forwards it to its own parent process when one holds the channel. A script that spawns a dev server can finally wait for the boot to finish without reading the child's output.
6+
7+
`✓ Server is ready` is true about the HTTP server and says nothing about the app. Seeding races a soft budget (`OS_INLINE_SEED_BUDGET_MS`, default 8s) and past it finishes in the background, so the banner can be a minute ahead of the seed's own result — measured downstream at **82 seconds of silence after the banner, then 120 `ERROR` lines**. The same command on the same corpus settles before the banner on a machine where the seed fits its budget, so the defect is invisible on exactly the boxes that would have caught it. Everything that distinguishes the two cases arrives on the child's inherited stdio, and reading that costs the boot its TTY.
8+
9+
- **The producer is not new.** `@objectstack/runtime` already declares every seed source and settles it at the moment its boot-time write is done, publishing the tally under `@objectstack/spec`'s `seed-settlement` contract. This is the hop outward: the CLI subscribes to two hooks the kernel already fires and reads a snapshot it already publishes. No service is registered and no tally is mutated — the contract is read-only by design.
10+
- **Sent once, and never before `objectstack:listening`.** Seeding that settles during `runtime.start()` is latched and released after the bound port is published, so a parent that waits for the listening message and only then listens for the settle cannot miss it.
11+
- ⛔ **Keyed on `inFlight`, not `pending`.** Multi-tenant replay and `skipSeedData` register a seed source and deliberately never run it, keeping `pending` above zero for the life of the process. A `pending`-keyed message would never be sent on those boots, and its absence would be indistinguishable from a boot still writing — the same ambiguity this closes, one level up. Those boots get the message with `suppressed` reasons attached instead, so a consumer can say *why* no rows landed.
12+
- **Failure settles too.** A seed that failed has still come to rest; withholding there would recreate the hang. `ok` is a verdict on the per-source counts the boot recorded, and the message carries those counts.
13+
- **The over-budget banner no longer omits seeding.** `Seeds:` is fed by outcomes recorded when a load *finishes*, so past the budget the row was ABSENT and the transcript was byte-identical to an app that declares no seeds — which is how the defect hid. It now reads `pending — N sources still writing`, with a line saying seeding continues in the background; suppressed sources are named rather than reported as pending.
14+
15+
⛔ An ipc channel is **not** made a requirement of either command: `process.send` is undefined under an ordinary terminal boot, both sends are no-ops there, and no byte of that transcript changes. Nothing in the existing `objectstack:listening` publication moves.
16+
17+
Note that `os dev` consumes `objectstack:listening` itself (it is how the bound-port readout and the MCP connect hint learn the real port) and relays only `objectstack:seed-settled`. Spawn `os serve` directly to receive both in one place.

‎content/docs/deployment/cli.mdx‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,67 @@ audit) lands there instead of the business DB (ADR-0057). Opt out with
234234
`OS_TELEMETRY_DB=0`, or point it elsewhere (any mode, including `serve`)
235235
with `OS_TELEMETRY_DB=<path>`.
236236

237+
##### Waiting for the boot from a parent process
238+
239+
`✓ Server is ready` is true about the **HTTP server**, and deliberately says
240+
nothing about the app's data. Seeding races a soft budget
241+
(`OS_INLINE_SEED_BUDGET_MS`, default `8000`); when it runs long the kernel
242+
starts anyway and the rest of the seed finishes **in the background** — so the
243+
banner, and anything that waits for it, can be a minute ahead of the seed's own
244+
result. On a machine where the seed fits its budget the same command settles
245+
before the banner. Both are normal, and which one you get depends on the box.
246+
247+
So a script that spawns a dev server and wants to act **after the boot has come
248+
to rest** should not wait on the banner, and should not need to read the child's
249+
output at all. Spawn with an `ipc` channel and wait for a message:
250+
251+
| Message | Sent by | Means |
252+
|---|---|---|
253+
| `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. |
254+
| `objectstack:seed-settled` | `os serve` | Nothing is still seeding. Carries `{ ok, suppressed, sources }`. Sent once per boot, always **after** `objectstack:listening`. |
255+
256+
```js
257+
import { spawn } from 'node:child_process';
258+
259+
const child = spawn('os', ['dev'], { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] });
260+
261+
child.on('message', (msg) => {
262+
if (msg?.type !== 'objectstack:seed-settled') return;
263+
if (msg.suppressed.length > 0) {
264+
console.log(`boot complete — seeds not run this boot (${msg.suppressed.join(', ')})`);
265+
} else if (!msg.ok) {
266+
console.log('boot complete — but some seed records did not land; see the log above');
267+
} else {
268+
console.log('boot complete — the app is ready to use');
269+
}
270+
});
271+
```
272+
273+
**`os dev` spawns `os serve`, and the two channels are not symmetric.** `os dev`
274+
consumes `objectstack:listening` itself — it is how the `↪ server bound to port`
275+
line and the MCP connect hint learn the real port — and does **not** relay it.
276+
It forwards `objectstack:seed-settled` to its own parent verbatim. Spawn
277+
`os serve` directly if you need both messages in one place.
278+
279+
An `ipc` channel is optional: without one, both sends are no-ops and nothing
280+
about the command changes. There is no polling to do — if you did not open the
281+
channel, the messages simply are not sent.
282+
283+
<Callout type="info" title="What `objectstack:seed-settled` promises, and what it does not">
284+
It is sent when **nothing is still writing** — on success *and* on failure, since
285+
a seed that failed has still come to rest. Read `ok` together with `sources`
286+
rather than alone: `ok` is a verdict on the per-source counts the boot recorded,
287+
and a source that finished by throwing may record no counts at all.
288+
289+
`suppressed` is non-empty when this boot registered a seed source and
290+
deliberately never ran it — `multi-tenant-replay` (rows are written per
291+
organization on `sys_organization` insert) or `skip-seed-data` (a planning boot
292+
that writes nothing). Those sources never settle and no further signal is
293+
coming for them, which is exactly why the message is sent anyway with the reason
294+
attached: a consumer that waited for *every* source to finish would wait
295+
forever.
296+
</Callout>
297+
237298
#### `os serve`
238299
239300
Starts the ObjectStack server with automatic plugin discovery:
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { forwardSeedSettledToParent } from './dev.js';
5+
6+
/**
7+
* #17329 — `os dev` relays the `serve` child's settle announcement to its OWN
8+
* parent, and does nothing at all when no parent holds the channel.
9+
*
10+
* ## Why the hop is the card
11+
*
12+
* The producer already exists and is published: `@objectstack/runtime` declares
13+
* every seed source and settles it at the moment its boot-time write is done,
14+
* under the spec's `seed-settlement` contract. `serve` now announces that on the
15+
* ipc channel. But the consumer — a demo script, a test harness, anything that
16+
* spawns a dev server and wants to print one line after the boot — spawns
17+
* `os dev`, not `serve`; `os dev` runs the child over
18+
* `stdio: ['inherit','inherit','inherit','ipc']`, so without this the message
19+
* lands in the middle process and stops. One hop is the whole of what was
20+
* missing.
21+
*
22+
* ⚠️ Under vitest's `forks` pool `process.send` is the RUNNER's own control
23+
* channel. Every swap below is synchronous, spans one call, and is undone in
24+
* `finally` — a real message must never reach it.
25+
*/
26+
describe('#17329 `os dev` forwards `objectstack:seed-settled` outward', () => {
27+
/** Drive `fn` with `process.send` replaced by a recorder. */
28+
const recording = (fn: () => void): unknown[] => {
29+
const sent: unknown[] = [];
30+
const prior = process.send;
31+
(process as { send?: unknown }).send = (m: unknown) => { sent.push(m); return true; };
32+
try { fn(); } finally { (process as { send?: unknown }).send = prior; }
33+
return sent;
34+
};
35+
36+
/** Drive `fn` with NO ipc channel — the ordinary terminal `os dev`. */
37+
const withoutChannel = <T>(fn: () => T): T => {
38+
const prior = process.send;
39+
(process as { send?: unknown }).send = undefined;
40+
try { return fn(); } finally { (process as { send?: unknown }).send = prior; }
41+
};
42+
43+
const settled = {
44+
type: 'objectstack:seed-settled',
45+
ok: false,
46+
suppressed: [],
47+
sources: [{ source: 'showcase', inserted: 24, updated: 0, skipped: 0, rejected: 14 }],
48+
};
49+
50+
it('relays the message VERBATIM, not a re-derivation of it', () => {
51+
// ⛔ This process has no kernel and could only guess. Passing the object
52+
// through is what keeps `os dev`'s parent and the `serve` child from being
53+
// made to say two different things about one boot.
54+
const sent = recording(() => {
55+
expect(forwardSeedSettledToParent(settled)).toBe(true);
56+
});
57+
expect(sent).toEqual([settled]);
58+
expect(sent[0], 'the message was rebuilt rather than relayed').toBe(settled);
59+
});
60+
61+
it('⛔ a parent with no ipc channel is UNAFFECTED — no throw, no send', () => {
62+
// An ipc channel must not become a requirement of running a published
63+
// command. `process.send` is undefined under a terminal `os dev`.
64+
withoutChannel(() => {
65+
expect(() => forwardSeedSettledToParent(settled)).not.toThrow();
66+
expect(forwardSeedSettledToParent(settled), 'the message is still HANDLED here').toBe(true);
67+
});
68+
});
69+
70+
it('survives a parent channel that has already closed', () => {
71+
// Best-effort, exactly like the child's own `announceListening`: a
72+
// supervision nicety must never take a healthy dev server down.
73+
const prior = process.send;
74+
(process as { send?: unknown }).send = () => { throw new Error('channel closed'); };
75+
try {
76+
expect(() => forwardSeedSettledToParent(settled)).not.toThrow();
77+
} finally {
78+
(process as { send?: unknown }).send = prior;
79+
}
80+
});
81+
82+
describe('⛔ and it claims ONLY its own message', () => {
83+
it.each([
84+
['the listening announcement', { type: 'objectstack:listening', port: 3001, url: 'http://localhost:3001' }],
85+
['an unrelated type', { type: 'something:else' }],
86+
['no type at all', { port: 3001 }],
87+
['null', null],
88+
['undefined', undefined],
89+
['a string', 'objectstack:seed-settled'],
90+
])('%s is left to the caller', (_label, msg) => {
91+
// Returning `true` here would swallow `objectstack:listening` and take
92+
// the bound-port readout and the MCP connect hint down with it.
93+
const sent = recording(() => {
94+
expect(forwardSeedSettledToParent(msg)).toBe(false);
95+
});
96+
expect(sent, 'a message that is not ours was forwarded anyway').toEqual([]);
97+
});
98+
99+
it('…and the positive control on the same path still fires', () => {
100+
// So the zeros above are readings rather than a function that forwards
101+
// nothing at all.
102+
const sent = recording(() => { forwardSeedSettledToParent(settled); });
103+
expect(sent).toHaveLength(1);
104+
});
105+
});
106+
});

‎packages/cli/src/commands/dev.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,50 @@ export function printMcpConnectHint(
131131
console.log(chalk.dim(' Disable OS_MCP_SERVER_ENABLED=false'));
132132
}
133133

134+
/**
135+
* The hop outward: relay the `serve` child's `objectstack:seed-settled`
136+
* announcement to `os dev`'s OWN parent (#17329).
137+
*
138+
* ## Why the hop exists at all
139+
*
140+
* `os dev` is a spawner. It runs `serve --dev` over
141+
* `stdio: ['inherit','inherit','inherit','ipc']`, so the child's settle
142+
* announcement lands HERE and stops — while the consumer that needs it (a demo
143+
* script, a test harness, anything that spawns `os dev` and wants to print one
144+
* line after the boot) holds a channel to `os dev`, not to a grandchild process
145+
* it did not start and cannot name. One hop is the whole of the missing piece:
146+
* the producer already exists, and the child already announces.
147+
*
148+
* ## Relayed verbatim, deliberately
149+
*
150+
* ⛔ Nothing here re-derives, re-summarises or re-grades the message. The child
151+
* read the settlement tally off the kernel that did the seeding; this process
152+
* has no kernel and could only guess. Passing the object through means `os
153+
* dev`'s parent and the `serve` child can never be made to say two different
154+
* things about one boot — the same rule the `MCP:` row above follows for the
155+
* origin, and for the same reason.
156+
*
157+
* ## An IPC channel stays OPTIONAL for this command
158+
*
159+
* ⛔ A parent that holds no channel must be unaffected, and is: `process.send`
160+
* is `undefined` under an ordinary terminal `os dev`, so this returns having
161+
* done nothing, printed nothing, and changed no byte of that transcript. The
162+
* `serve` child's own `announceListening` is best-effort for exactly this
163+
* reason and this is its mirror — ⛔ this message does not make an IPC channel
164+
* a requirement of running a published command.
165+
*
166+
* @returns `true` when the message was a settle announcement (handled here, and
167+
* the caller should stop) — `false` for every other message, which the
168+
* caller's own branches still own.
169+
*/
170+
export function forwardSeedSettledToParent(msg: unknown): boolean {
171+
if ((msg as { type?: unknown } | null | undefined)?.type !== 'objectstack:seed-settled') return false;
172+
try {
173+
if (typeof process.send === 'function') process.send(msg);
174+
} catch { /* the parent's channel closed — best-effort, exactly like the child's */ }
175+
return true;
176+
}
177+
134178
export default class Dev extends Command {
135179
static override description =
136180
'Start development mode — watch sources, rebuild the artifact, and restart the server on change';
@@ -566,6 +610,10 @@ export default class Dev extends Command {
566610
// its HTTP server is up. We surface it so the printed URL is correct
567611
// even when the port was auto-shifted (e.g. 3000 busy → 3001).
568612
child.on('message', (msg: any) => {
613+
// #17329 — the hop outward. Handled first and exclusively: a settle
614+
// announcement carries no port and has nothing to do with the block
615+
// below. See {@link forwardSeedSettledToParent}.
616+
if (forwardSeedSettledToParent(msg)) return;
569617
if (msg?.type === 'objectstack:listening' && msg.port) {
570618
const actual = String(msg.port);
571619
if (actual !== requestedPort) {

0 commit comments

Comments
 (0)