Skip to content

test(cli): every bespoke CLI spawner takes a kernel-assigned port and reads it back; the released-port ledger keeps only deliberate draws - #24396

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24342-s2-bespoke-spawners
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-24342-s2-bespoke-spawners

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24342

Clause-②: no

Basis for the Clause-② line: every changed file is under packages/cli/test/**, the CLI's test harness. @objectstack/cli publishes dist, README.md and CHANGELOG.md (its files[]). Measured after pnpm --filter @objectstack/cli build: the symbols this PR spreads or edits (KERNEL_ASSIGNED_PORT, readKernelAssignedPort, RELEASED_DRAW_LEDGER) occur in 0 of those files. The positive control childPortEnv, which comes from src/, occurs in 2. Nothing published moves, so this PR carries the skip-changeset label and no changeset.

This is slice 2 of #24342 and the card's last: the 25 files slice 1 (PR #24386, c76e84922) left in RELEASED_DRAW_LEDGER with the reason slice 2 of #24342. After it, no ledger entry carries a slice reason. Two files stay ledgered with their real reasons, so the card's "Done when" holds: every spawner takes the closed mechanism or is named exempt with its reason.

Size

921 changed lines (+349 / -572, 25 files), measured by dispatch-gates.mjs against merge base cf16a0d18. That is under the card's 1,000-line split line, so all six groups of slice 1's "What slice 2 converts" are in this PR and there is no slice 3.

What changed, by slice 1's groups

The recipe is the same everywhere: spawn on KERNEL_ASSIGNED_PORT (--port 0 / -p 0), read the bound port back from the child's own output, and build each boot's base URL from its own read-back. A file that reuses one drawn port across several boots now reads each boot's port separately.

  • Bespoke boot helpers that settled on portDriftError() (14 files, 16 draws).
    • Files: artifact-flag-precedence, package-install-local-{boot-steps,handlers,hooks,jobs,jobs-pull,jobs-shared-name,uninstall-cleanups}, package-restart-acceptance, serve-config-boot-flat-docs, serve-config-boot-residual (its bootRefused() too), serve-host-config-metadata-door, serve-organization-record-page, serve-view-container-read.
    • The settle step is now the read-back verdict, as slice 1's A4 did for runServe(). It settles on readKernelAssignedPort(out, what), which resolves with the bound port, or rejects when a complete banner names no non-zero http://localhost port.
    • The exit handler drops portContentionError(). That helper says "⛔ Not for a child spawned on KERNEL_ASSIGNED_PORT", because such a child was handed no number to lose. An early exit now rejects with the child's own output.
    • Files with a LiveServe that carried no base gain port, and the caller builds base from it. package-restart-acceptance builds a second base for boot 2. package-install-local-uninstall-cleanups reads 8 boots, and each one's base is its own.
  • Their own banner read-back (2): serve-runtime-assets-branding-warning, start-signal-forwarding. reservePort() and boundPortFromBanner() become KERNEL_ASSIGNED_PORT and readKernelAssignedPort().
  • Copies of the runServe() shape (3):
    • serve-app-anchored-optional-import (runServeFrom): the contention and drift checks become the read-back, taken only off a complete banner. The undeclared leg dies before listen() and announces no port. The header's port-apparatus section is rewritten to say so.
    • serve-process-child-env: probeOrigin() defaults to KERNEL_ASSIGNED_PORT. Its readyVerdict() already handled a requested 0 through portDriftError()'s 0 branch. The forced-drift arm still hands over a held port on purpose.
    • serve-migrate-exit-schema-parity: the one --port argument.
  • bin/run.js --dev / os dev (4):
    • dev-boot-output-order: the pty command line takes -p 0.
    • serve-mcp-capability-collision, serve-mcp-stdio-answers, serve-stdio-stdout-purity: the module-level drawn port is gone, and so is each file's local portDriftError() with its docblock. The local boundPort() gate stays: a boot resolves only once a port is named. It resolves with that port, and rejects at once on a 0, which would be the request announced back. Each boot's HTTP base comes from boot 1's read-back.
  • The drift notice's NEGATIVE arm (serve-port-drift-notice): measured, then kept and ledgered, not converted (see below).
  • Deliberate draws (serve-publishes-bound-port, 2): re-ledgered with their real reasons.

The two entries left in RELEASED_DRAW_LEDGER

SLICE_2 is deleted. Each remaining entry names why --port 0 read back afterwards would change what the file proves:

  • serve-port-drift-notice.e2e (1). The NEGATIVE arm is a boot that binds exactly the non-zero port it asked for and says nothing about ports. Measured on this head, with the arm's own spawn (tsx serve, bare config, NODE_ENV=development, default log level):
  • serve-publishes-bound-port.e2e (2), as slice 1 named them. reservePort() is the connect instrument's NEGATIVE arm: a port probed free and left unbound, with nothing spawned on it. randomPort() is the non-zero request that the ordinary-boot arm is about, and that arm reads a lost race out of the child's own drift notice instead of failing on it.

The pin's other tests are unchanged. The positive controls package-install-local-handlers (harness CLI) and start-signal-forwarding (spells bin/run-dev.js) are still in the population. "Every ledger entry still names a file in the population" still guards against a stale row.

Helper docblocks (test/helpers/serve-process.ts, comments only)

reservePort(), randomPort() and portDriftError() now say what may still draw:

  • instruments that need a number nothing listens on, or that measure the draw itself;
  • a CLI spawn whose subject is a non-zero request, which the pin's ledger names with its reason.

No helper code changed.

Reverse verification (one-time, not kept)

The pin reads source through the TypeScript AST, so no build or dist preflight applies.

  • Mutation, through scripts/ablation-replace.mjs (WRAP mode). In serve-migrate-exit-schema-parity.integration, '--port', KERNEL_ASSIGNED_PORT, ...extraArgs became '--port', randomPort(), ...extraArgs, which is this file's line before the conversion.
    • The tool reported anchor 1 → 0, replace x0 → x1, and blob 9d7ef446bad7 → febdea4dc573.
  • Result. The pin went Tests 1 failed | 3 passed (4), naming serve-migrate-exit-schema-parity.integration.test.ts: 1 released-port draw(s) (line 96), ledger allows 0. The file has no ledger entry any more, so one draw is a red.
  • Restore. Blob after restore 9d7ef446bad71d7fd5aba91e23f3265d414925e4 == HEAD's blob. git diff HEAD was empty, and git status --porcelain was empty, read separately afterwards.
  • The negative-arm probe above (control versus --port 0) was a scratch script outside the repo. Both children were stopped and awaited, and the tree is clean.

Tests

All on head 4fb88734c, base cf16a0d18. The dependency closure was built first with turbo run build --filter='@objectstack/cli^...' (60/60, VERDICT command-exit 0), then pnpm --filter @objectstack/cli build. Every run went through scripts/pm/os-verify-lock.sh with a slot. Each file ran in the tier it lives in, and the tier of every changed file is unchanged from base (measured with vitest-tiers.ts's own predicate at base and head: 24 test files, 0 changed; the pin stays unit).

  • unit: released-port-production-spawn.pin 4/4.
  • Nightly tier (OS_TEST_TIERS=nightly, --project integration --maxWorkers=2):
    • serve-app-anchored-optional-import, serve-organization-record-page, serve-process-child-env, serve-runtime-assets-branding-warning, start-signal-forwarding: 5 files, 23/23.
    • serve-mcp-capability-collision, serve-mcp-stdio-answers, serve-stdio-stdout-purity: 3 files, 5/5.
  • Queue tier (--project integration --maxWorkers=2):
    • serve-config-boot-flat-docs, serve-config-boot-residual, serve-host-config-metadata-door, serve-migrate-exit-schema-parity, serve-view-container-read, package-restart-acceptance: 6 files, 37/37.
    • artifact-flag-precedence, dev-boot-output-order: 2 files, 13/13.
    • package-install-local-{boot-steps,handlers,hooks,jobs-pull}: 4 files, 40/40.
    • package-install-local-{jobs-shared-name,jobs,uninstall-cleanups}: 3 files, 37/37.
  • In all: 23 converted files, 155/155, plus the pin's 4/4.
  • pnpm --filter @objectstack/cli typecheck: exit 0. The test layer holds its 3-file / 28-error ledger. tsc --listFiles -p tsconfig.test.json puts all 25 changed files in the program, and the 28 errors are all in data-model-rules (23), i18n-extract-action-description (4) and i18n-declared-surface-gate (1), none in a changed file.
  • pnpm exec eslint --no-inline-config over the 25 changed files: exit 0.
  • Not merged with origin/main. Main has moved to 584b4225e since the base. Its 3 new commits touch packages/cli/src (organization ownership, verify.ts) and none of the files here, and there is no conflict. So, per the dev contract (merge only on a conflict, a main-caused red or a dequeue), CI's merge ref is what reads the joint tree.

Gates

Local subset, head 4fb88734c.

  • node scripts/pm/dispatch-gates.mjs --commands derives 52 commands (change set 25 paths vs merge base cf16a0d18, 921 changed lines). Each ran with its exit code captured before any pipe.
    • 51 exited 0 on the first pass.
    • check:dual-build-cjs-loads first read PREREQUISITE NOT MET (exit 3: some packages outside the CLI closure had no dist/). Re-run once those dist/ trees existed, it read exit 0: 111 published require entry point(s) across 69 package(s) load.
  • --ran reconciliation: Run reconciliation — 52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN.
  • The 11 declared-wide families (check:init-service-contract, check:live-db-isolation, check:meta-type-normalized, check:optional-error-sink, check:resume-authority-declared, check:route-envelope, check:runner-env-posture, check:settings-bind-window, check:startup-registry-verdict, check:verify-stand-in, check:wildcard-fallthrough): all 11 exited 0. So did the three artifact-roster gates whose roster sits under packages/ (check:authz-resolver, check:error-code-casing, check:filter-alias-parity).
  • check:cli-test-child-env: 155 spawner source(s) among 305 under packages/cli/test/**; no new bulk process.env copy reaches a spawned child, all 189 spawn call(s) declare their child's env, exit 0.
  • check:nul-bytes: no raw ASCII control bytes, exit 0.
  • Not run locally: check-platform-checklist (no exported symbol was renamed or deleted; the deleted portDriftError copies and SLICE_2 were module-local). The repo-wide pnpm lint and the CI farm are CI's, and are read from this PR's check-runs.

Acceptance notes


Generated by Claude Code

… reads it back

Slice 2 of #24342. The 23 development-column files that spawned the CLI
themselves on a released port now spawn on KERNEL_ASSIGNED_PORT and read the
bound port back off the child's own output; each boot builds its base URL
from its own read-back. The dead local drift checks go with them. The two
draws that are an arm's subject stay ledgered with their real reasons.

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 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 584b4225e1fcbaf5e8bed14d9c280d6347a897ff → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT (lane review) · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T20:35Z · on head 4fb88734c · #24342 slice 2, the card's last (claim 6254381649, report 6254823229). This is the seat's own review at the claimed default tier, read against GitHub, not the report.

  1. Shape:
    • Draft against main. The first line is Fixes #24342, which is right: after this PR no ledger entry carries a slice reason.
    • Clause-②: no is line-initial, and its basis is measured: 0 of @objectstack/cli's published files carry the touched symbols, with positive control childPortEnv at 2.
    • 25 files, +349 / −572 (921 changed lines, under 1,000, so the slice is not split). skip-changeset. Not governed, under 3,000. Assignee zhuangjianguo.
    • Everything is inside packages/cli/test/**. No hold-off path is touched.
  2. Conversions, read on the diff (sampled: package-install-local-handlers, serve-mcp-stdio-answers, and the pin):
    • Each spawner passes KERNEL_ASSIGNED_PORT and settles on readKernelAssignedPort() (or its own read-back, now rejecting an announced 0).
    • Each boot builds its base URL from its own read-back, so the files that reused one drawn port across 2 to 8 boots now read each boot separately.
    • The dead portDriftError() / portContentionError() calls on those paths are gone.
  3. The pin:
  4. Reverse verification, from the dev's run: a reverted serve-migrate-exit-schema-parity turns the pin red (1 failed | 3 passed (4), line 96 named). The restore was proven: blob == HEAD, git diff HEAD empty.
  5. Tests and gates:
    • 23 converted files, 155/155, each run in its own tier. The tier predicate reads files=24 changed=0 against base. The pin passes 4/4.
    • cli typecheck holds its 3-file / 28-error ledger, with 0 errors in changed files. eslint exits 0.
    • dispatch-gates: 52 derived, 52 run, 0 NOT-MEASURED. check:cli-test-child-env exits 0.
    • CI on this head was running at this stamp, with none red.
  6. Out of scope:
  7. Deviation accepted: origin/main was not merged, because no conflict, main-caused red or dequeue occurred. git merge-tree against 584b4225e is clean, and the merge ref CI reads the joint tree.
  8. Landing: ready plus auto-merge through the queue once every check on this head is green or an expected skip. The card closes on the merge. test budget (domain:cli): 119 test file(s) over 10 s of wall in the hourly full battery (Part of #23151) #23181 (the cli test budget, the same files) was waiting behind it.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants