Skip to content

test(cli): the built-CLI refusal said every boot times out; the child exits 2 at once (#12618) - #12648

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12618-unbuilt-cli-refusal-clause
Aug 27, 2026
Merged

test(cli): the built-CLI refusal said every boot times out; the child exits 2 at once (#12618)#12648
os-litant merged 1 commit into
mainfrom
claude/issue-12618-unbuilt-cli-refusal-clause

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12618

RUN_JS_RESOLVES_FROM_DIST ended "… so on an unbuilt tree the child answers "command serve not found"and every boot below times out." The first clause is exactly right. The second is not what happens — and this is the sentence a developer on a fresh worktree reads instead of debugging, so a false explanation here costs the round the refusal exists to save.

Measured, on this branch

Reproduced end to end with the card's own recipe, on a closure-only tree (pnpm --filter '@objectstack/cli^...' build, no packages/cli/dist):

$ env -u NODE_ENV node packages/cli/bin/run.js serve --help
› Error: Command serve not found.
EXIT=2 ELAPSED=198 ms

The child exits 2 immediately. All four bin/run.js spawners resolve the boot promise from their child.on('exit') handler, which rejects with serve exited ${code} before ${waitFor} — never from the timer, which is a different message (serve never printed … / serve never reached …) at 150 s:

filechild.on('exit')timer
serve-mcp-capability-collision.e2e.test.ts:246 (rejects :250)150_000:213
serve-mcp-stdio-answers.e2e.test.ts:242 (rejects :247)150_000:210
serve-stdio-stdout-purity.e2e.test.ts:238 (rejects :244)150_000:207
serve-node-env-production-default.e2e.test.ts:374150_000:365

198 ms against a 150 s timer. (The card measured 178 ms; that is my number, not a reconciliation of it.)

The tree already refuted itself — in three places, all left intact

None of these is a copy of the clause; each records the real behaviour and none was edited:

  • helpers/serve-process.ts:35 — 28 lines above the false clause, in the same file: "an unbuilt worktree answered › Error: command serve not found, the harness reported serve exited 2 before "Server is ready"".
  • serve-built-cli-prerequisite.test.ts:14 — this pin file's own header, same sentence.
  • serve-node-env-production-default.e2e.test.ts:206 — two paragraphs below the docblock that vouches for "times out": "this file reported 3 tests | 1 failed: the unset pin failed with serve exited 2 before "Server is ready"".

The census — re-derived, not inherited

Swept for the clause's fingerprints and reverse-checked the instrument both ways, so a zero is a reading rather than a broken grep:

legresult
instrument (every boot below times out)exit 0, 3 hits
positive control (oclif, a term the population must contain and not a substring of the test term)6 / 5 / 5 hits in the same three files
negative control (every boot below hangs forever)exit 1, 0 hits
untracked sweep (grep -rn, files git grep cannot see)same 3
paraphrase sweep over the whole population (the 6 files naming requireBuiltCli / RUN_JS_RESOLVES_FROM_DIST), for times? out / timed out / timeout / hang / stall / never readyno further hit asserting a hang on an unbuilt tree

Exit codes captured before any pipe (cmd > file; EXIT=$?), after one measured miss where EXIT=$? behind | cat read cat's status and printed 0 for a grep that had matched nothing.

UNSET_LEG_MEASURES_THE_BUILT_DIST (:208) does not carry the clause, was not flagged by the sweep, and is untouched.

The three sites, all repaired in this one commit:

  1. packages/cli/test/helpers/serve-process.ts:63 — the constant.
  2. packages/cli/test/serve-built-cli-prerequisite.test.ts:103 — the byte-for-byte .toBe() pin. Updated in the same commit, and still .toBe() — not relaxed to toContain, not split. The pin doing its job is why this is a card of its own.
  3. packages/cli/test/serve-node-env-production-default.e2e.test.ts:189 — the docblock that quotes and vouches for the clause. Repairing 1 and 2 alone would leave the tree still saying the false thing, with the authority of a file explaining why it deliberately does something else.

A fourth site, beyond the three: a meta-claim about the pinned bytes

serve-built-cli-prerequisite.test.ts:92 was titled it('reproduces, byte for byte, what the three private copies threw before the hoist'), and its comment said the literal "is the message measured on 09b4f4e4e". Both become false the moment the pin is deliberately reworded — a true pin carrying a false explanation, which is the exact defect class this card is about. Retitled to it('pins the whole refusal, byte for byte'), with the comment now recording that #12539 moved the message without rewording it and that this change reworded one clause. No assertion changed: the .toBe() still compares the whole message. Flagged explicitly because it is beyond the three sites the fingerprint grep can reach.

The replacement

"command serve not found" and every boot below fails immediately with "serve exited 2", not a timeout.

One clause. "fails immediately" alone would lose the actionable half, so the clause names the message the reader will actually see. Every word is checkable against the table above, and the true first clause is untouched.

Anti-vacuity — the pin still bites

A green after a prose edit is indistinguishable from a green after the pin stopped comparing anything. So the constant was mutated by one character (serve exited 2serve exited 3) with the pin left alone. Confirmed on disk before reading any verdict — anchored injected count 1, anchored removed count 0, blob 3147b0cbc4c6308a, 1 insertion 1 deletion — and the pin red, naming the mismatch:

FAIL test/serve-built-cli-prerequisite.test.ts > pins the whole refusal, byte for byte
AssertionError: expected '…' to be '…' // Object.is equality
- … and every boot below fails immediately with "serve exited 2", not a timeout.
+ … and every boot below fails immediately with "serve exited 3", not a timeout.

Restored under a trap … EXIT INT TERM with absolute paths, via git checkout HEAD -- <path> (named HEAD, never bare — a bare restore reads the polluted index). Restore proven by blob hash and an empty diff, never by an exit code: worktree blob 3147b0cb60d8a129 identical to the non-empty HEAD blob, git diff HEAD0 bytes, git status --porcelain empty.

The first run of this leg failed loudly and measured nothing — the removal assertion counted a bare serve exited 2, which also occurs in the file's counter-witness comment at :35. The anchor was widened to with "serve exited 2" and the leg re-run. Recorded because a mis-specified check that had merely been loosened until it passed would have been the same defect one level up.

Nothing but prose changed

Both blobs of all three edited files transpiled with removeComments: true and the emitted program hashed:

fileexpectationemitted
helpers/serve-process.tsstring literal — hash must move91b6582bb889a723
serve-built-cli-prerequisite.test.tsstring literal — hash must move2a46d71db3aab098
serve-node-env-production-default.e2e.test.tscomment only — hash must not movedd50ec0897b3cde7identical

Sites 1 and 2 are string literals, not comments, so they move the emitted hash legitimately — an inequality here is expected, not a failed proof. The emitted-program diff for those two is exactly the literals and nothing else:

- '"command serve not found" and every boot below times out.';
+ '"command serve not found" and every boot below fails immediately with "serve exited 2", ' +
+ 'not a timeout.';
- it('reproduces, byte for byte, what the three private copies threw before the hoist', () => {
+ it('pins the whole refusal, byte for byte', () => {

No requireBuiltCli() logic, no change to which files call it, no spawner timeout, no exit handling.

The instrument was reverse-checked in both directions on the comment-only file: mutating a comment token leaves the hash at dd50ec0897b3cde7 (correct — comments are invisible), mutating a code token moves it to 1e1eaa96 (correct — code is visible). Without both legs the equality would mean nothing.

Verification

All of the below on the final commit, 732fe0cbd.

checkverdict, quoted from the check's own output
serve-built-cli-prerequisite.test.ts (unbuilt tree)Test Files 1 passed (1) · Tests 7 passed (7)
serve-built-cli-prerequisite.test.ts (built dist/, the branch CI takes)Test Files 1 passed (1) · Tests 7 passed (7)
pnpm --filter @objectstack/cli buildEXIT=0
pnpm lint (repo-wide, eslint . --no-inline-config)VERDICT command-exit 0 · held the lock 93s — full farm, no narrowing claimed
pnpm check:cli-test-child-env✓ … 36 spawner source(s) among 100 under packages/cli/test/**all 4 spawn(s) of the built CLI keep their child out of development/test
pnpm check:nul-bytescheck-nul-bytes: OK (scanned 6987 text file(s) … no raw ASCII control bytes)
node scripts/check-comment-mask-adoption.mjsOK check:comment-mask-adoption — 20 private comment-stripper(s) … all 20 recorded
pnpm check:type-check-coverage✓ … 47 semantic case(s) + 65 observation case(s) … hold.
pnpm check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned
pnpm check:cross-package-test-inputsOK: 20 package(s) read outside themselves, all declared
both edited e2e files collectvitest list — 4 tests enumerated, exit 0

Families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reported the answer as derived from objectstack-ai/objectstack at 732fe0cbd. The 9 packages/spec families it lists under "THE LAYOUT MOVED" are #12514 and are not refiled here.

NOT MEASURED, stated rather than implied

pnpm --filter @objectstack/cli typecheck exits 0, and that green says nothing about these three files. packages/cli/tsconfig.json declares include: ["src"], so packages/cli/test/** sits outside the tsc program entirely: tsc --noEmit --listFiles lists 1279 files, 205 under packages/cli/src/ and 0 under packages/cli/test/. Reverse-checked — the src/ count is the positive control proving the grep works. This is a ledgered posture, not a regression (check:type-check-coverage is green and records 18 packages that hide their own tests), and this change does not move it. Reported as unmeasured rather than as a pass.

The four bin/run.js e2e suites were not run — a declared narrowing. The constant they consume is read only by requireBuiltCli() in beforeAll, which returns silently on a built tree, so the string is never reached on the path CI takes; the transpile proof above shows no code moved in any of them. Both edited e2e files were confirmed to parse and collect. CI runs the farm regardless.

No changeset

packages/cli publishes files: ["dist", "README.md", "CHANGELOG.md"]. This diff is confined to packages/cli/test/**, which is outside that set, so the change releases nothing and there is no user-visible behaviour to describe. skip-changeset applies. Reasoned from the publish surface, not carried over from another card.

Scope

Three files, prose only, one commit. packages/cli/src/commands/serve.ts (PR #12636) and .gitignore (PR #12631) are fenced and untouched — verified against the change set, 0 hits.


Generated by Claude Code

… exits 2 at once
`RUN_JS_RESOLVES_FROM_DIST` ended "... and every boot below times out." The
first clause is right; the second is not what happens. On a closure-only tree
the child answers ` > Error: Command serve not found.` and exits 2 in ~200 ms,
and all four `bin/run.js` spawners reject from their `child.on('exit')` handler
with `serve exited 2 before <waitFor>` — never from their 150 s timer. A reader
following the old clause looks for a hang there is none of, while the real
reason is already on the child's stderr.
One clause, three sites: the constant, the byte-for-byte `.toBe()` pin that
guards its wording, and the docblock in
`serve-node-env-production-default.e2e.test.ts` that quotes the clause and
vouches for it being true of the siblings. The pin moves in this same commit —
that is the pin doing its job, not an obstacle — and stays `.toBe()`.
The tree already refuted itself in three places, all left intact: the comment
above the constant, this pin file's own header, and the paragraph two below the
vouching docblock, each recording `serve exited 2 before "Server is ready"`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 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.

Coarse fallback — 23 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 c4f3263d903a446d1c612ff5cfd88988b9812d17packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9dca3797846fbc0c8d13c03d29eebbd79db32359 — the merge of head 732fe0cbdccf45026343caaf970e66842b14cfc1 into base c4f3263d903a446d1c612ff5cfd88988b9812d17, 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 9dca3797846fbc0c8d13c03d29eebbd79db32359 && git checkout 9dca3797846fbc0c8d13c03d29eebbd79db32359
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c4f3263d903a446d1c612ff5cfd88988b9812d17 732fe0cbdccf45026343caaf970e66842b14cfc1 && git checkout -B drift-repro c4f3263d903a446d1c612ff5cfd88988b9812d17 && git merge --no-ff 732fe0cbdccf45026343caaf970e66842b14cfc1
node scripts/docs-audit/affected-docs.mjs --json c4f3263d903a446d1c612ff5cfd88988b9812d17

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

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

Labels

skip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] the built-CLI refusal says "every boot below times out"; measured, the child exits 2 immediately and the boot rejects on exit

2 participants

@os-litant@claude