Skip to content

fix(cli): make a failed stderr write non-fatal on the published entry point - #16686

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-15564-run-js-stderr-error-listener
Sep 7, 2026
Merged

fix(cli): make a failed stderr write non-fatal on the published entry point#16686
os-sales merged 5 commits into
mainfrom
claude/issue-15564-run-js-stderr-error-listener

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #15564

The card asked for a measurement. It got one, and the answer flipped.

#15564 was filed NOT REPRODUCED, and its fence was the whole point of it:

Not "add the listener to be safe" — that is the shape this repo keeps clearing, a change with no measured question behind it. Establish first whether the class is reachable there at all. Symmetry is not evidence.

So no code was written until the probe was run.

1. The two existing probes were re-run first, as this seat's own baseline

Triage stated plainly that the numbers on the card were the previous dev's and had not been re-run, and that the whole card rested on them. Both reproduce, on bin/run.js, read end destroyed (stdio: ['ignore','ignore','pipe'], then child.stderr.destroy()), traced with a --import observer that installs no listener on process.stderr and wraps no write (uncaughtExceptionMonitor):

probe exit uncaughtException drained
definitely-not-a-command 2, 3/3 none 57 B (card: 57)
OBJECTSTACK_DEBUG=1 + unbuilt-spec hook 2, 3/3 none 35528 B (card: 35523)

⭐ The card's reading of its own null result was right: NOT REACHED, not GUARDED.

2. Why they read clean — measured, not argued

A timeline instrument on the drained legs (used only there — it wraps the write, so it never touches a crash-measurement leg) puts every byte those two legs produce at the very end of the run:

definitely-not-a-command      write #1  3231 ms  57 B      exit 3232 ms
OBJECTSTACK_DEBUG + unbuilt   writes #1-59  932-937 ms     (oclif warning blocks)
                              write #60     960 ms         exit 961 ms

Two conditions have to hold together, and each was isolated against a plain-node control with the same destroyed read end:

  • an event-loop turn between the failing write and process.exit. A failing write reports through libuv's completion callback, so a synchronous exit on top of it is never told. write + stay alive → EPIPE crash 3/3; write + process.exit() in the same synchronous run → 0/3, exits with its own status. Both probe legs are the second shape: everything they write lands after run() has settled, and handle() exits on top of its own report.
  • a raw process.stderr.write. Node's console.error carries ignoreErrors, which parks a temporary error listener across the write — so it cannot crash a process at any payload size: 1 MiB through console.error 0/3, 60 blocks through console.error 0/3, one line through process.stderr.write 3/3. The 59 oclif warning blocks in probe 2 are console.error. With a listener installed deliberately for that one question, that leg does raise exactly 1 write EPIPE — and node's own Console swallows it.

⇒ The two probes bounded reachability, exactly as the card said. They did not close it.

3. Widening the window reaches it

os serve is both conditions at once: printDiagnostic (src/commands/serve.ts, #7915) writes straight to stderr, and the boot around it is asynchronous, so the process is alive across the whole sequence — 21 writes spanning 1084 ms on the drained leg, and 16.6 s of writes on a boot that succeeds.

node bin/run.js serve objectstack.config.ts on examples/app-todo, read end destroyed:

uncaughtException  code=EPIPE  msg=write EPIPE
      at afterWriteDispatched (node:internal/stream_base_commons:159:15)
exit  code=1

3 of 3 runs, 3049-3433 ms in — the same frame and the same status #14858 traced on the dev shim. The same child read by a draining parent boots, serves, and exits 0 at a 20 s SIGTERM having written 7926 B over 16.6 s.

⇒ The crash costs the run at its first diagnostic line and 20 of its 21 stderr writes, on the entry point a customer's install actually runs (files names only dist, but npm packs a bin target regardless — #14874).

The change

bin/run.js attaches a no-op error listener to process.stderr before run(), with the measurement above written into the file. Deliberately not narrowed to error.code === 'EPIPE', for the reason bin/run-dev.js already records: every event there means one thing, the only channel it could be reported on is the stream that just failed, and there is no other action to take.

The listener is named (objectstackStderrErrorIsNotFatal), and that is a correction the ablation forced rather than a flourish — see below.

The pin, and the phantom check the ablation caught

test/published-entry-stderr-error-listener.e2e.test.ts manufactures the hazard inside the published binary's own process (the pattern its neighbour published-entry-stderr-nonblocking.e2e.test.ts already uses): one raw write to a destroyed pipe, then one loop turn. It carries a live positive control — a second arm that removes the listener in the child's process, nothing on disk — so the guarded arm's silence is a reading and not a zero.

⚠️ The first version of this pin was vacuous and the ablation is what said so. It polled process.stderr.listenerCount('error') > 0, and against a tree with the whole listener block deleted it still reported LISTENER ATTACHED after 20 ms — node's console.error parks an anonymous once('error', noop), so a count cannot tell this listener apart from that one. The count also doubled as the probe's only synchronisation point, so a slow box would have written before the attach and red a working tree. The listener is now named, the probe waits for that name, and a parity case holds the probe's mirror equal to the entry's spelling.

Ablation, on the corrected pin (fix committed first; mutation proved on disk by anchor counts and blob hash; restore proved by blob hash + empty git diff HEAD, never by an exit code):

listener present listener deleted
cases red 0 of 5 3 of 5
child SURVIVED, exit 7 UNCAUGHT code=EPIPE, exit 1

The 2 that stay green under ablation are the ones that must: the live positive control (it crashes either way, by design) and the serve-still-writes-raw premise case.

Risk, and the rollback

A long-running command (os serve, os dev, os start) whose reader has gone now keeps running and reports its own exit status, instead of dying on its first diagnostic write. That is the point of #14858 — the server is still serving and the caller still gets the CLI's own status — but it is a real behaviour change for a supervisor that destroyed the read end and relied on the crash to end the child. It is stated in the changeset because it ships to consumers.

Rollback is the three-line listener block in bin/run.js; nothing else in the diff changes behaviour.

验收备注

  • The new pin lands in the nightly lane, by its .e2e name. Under today's ci: e2e and live tiers move to a nightly run on main; PR and queue runs keep unit, integration and conformance (maintainer-directed, part B tiering) #16455 ruling *.e2e.test.* is excluded from the per-PR and merge-queue runs. The name follows the convention (it spawns a child) and matches both sibling suites on exactly this subject, so it is what the ruling prescribes — but the consequence is that a future edit to bin/run.js is not guarded per-PR by this file. Naming a file into a tier is a live choice for a NEW file; flagged for the reviewer rather than decided here. Run it with OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run test/published-entry-stderr-error-listener.e2e.test.ts (~2.5 s).
  • bin/run.js and bin/run-dev.js now hold the same measurement in two docblocks. Neither imports from the other (run-dev.js is tsx/src, run.js is node/dist, and both run the CLI at module top), so there is nothing to single-source. noted, not filed.
  • Two gates first answered exit 3 PREREQUISITE NOT MET rather than a verdict — check:dual-build-cjs-loads and check:i18n-coverage, both naming a build this worktree did not yet have. They were recorded as NOT MEASURED, never as a pass, and then re-run after the closure build on the merged head, where both report their own OK line. The final reconciliation below has no NOT-MEASURED entries left.

Verification

All readings below are against merge head a48dd2ab93 (origin/main merged in) unless stated.

  • Gate family: derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived unchanged after the merge, reconciled with --ran57 derived, 57 run, 0 NOT-MEASURED, 0 UNRUN, every one exit 0.
  • pnpm --filter @objectstack/cli typecheck — green; --listFiles confirms the new test file is in the tsconfig.test.json program, so that green covers it rather than skipping it.
  • pnpm --filter @objectstack/cli exec vitest run --project unit — 183 files, 2511 passed, 6 expected fail. Re-run after the merge because main moved packages/cli (§10).
  • The new pin — 5 passed; 3 red under ablation.
  • eslint . --no-inline-config — the whole repo, not a narrowing, so no narrowing needs proving: 6318 files linted, 0 findings, exit 0 at a48dd2ab93 (count read from --format json, exit code captured before any pipe).

Every exit code above was captured before any pipe (cmd > log 2>&1; EXIT=$?), and gate verdicts are read from each gate's own verdict line.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ


Generated by Claude Code

`bin/run.js` writes to `process.stderr` with no `error` listener, so an
`error` event there is an uncaught exception. #15564 was filed NOT
REPRODUCED, and its fence was explicit: symmetry with `bin/run-dev.js` is
not evidence, establish reachability first.

Both of the card's probes were re-run before anything was written here and
both still read clean — exit 2, no `uncaughtException`, 3/3 each. They were
not a guard, they were the wrong lifecycle: everything a failing invocation
puts on stderr is written after `run()` has settled, by `handle()`, which
exits on top of its own report, and a failing write reports through libuv's
completion callback that a synchronous exit is never told about.

Widening to a lifecycle that outlives its first failed write reaches it.
`os serve` on `examples/app-todo`, read end destroyed:

    uncaughtException  code=EPIPE  msg=write EPIPE
          at afterWriteDispatched (node:internal/stream_base_commons:159:15)
    exit  code=1

3 of 3 runs, 3049-3433 ms in — the same frame and status #14858 traced on
the dev shim. The same child read by a draining parent boots, serves, and
exits 0 after 7926 bytes over 16.6 s.

Pinned with the hazard manufactured inside the published binary's own
process, alongside a live positive control that removes the listener in the
child and re-crashes it, so the guarded arm's silence is a reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
The first ablation of this change caught a phantom check in its own pin: a
probe polling `process.stderr.listenerCount('error') > 0` reported
`LISTENER ATTACHED after 20 ms` against a tree with the whole listener
block deleted. Node parks an anonymous `once('error', noop)` on this stream
for the duration of every `console.error` (`ignoreErrors`), so a count
cannot tell this listener apart from that one, and the poll doubled as the
probe's only synchronisation point — a slow box would have written before
the attach and red a working tree.

The listener is named, the probe waits for that name, and a parity case
holds the probe's mirror equal to the entry's spelling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
@github-actions github-actions Bot added the size/l label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/cli/bin/run.js), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cli/bin/run.js) — pages documenting those are invisible to this run
  • 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 — 22 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 001a83b0486391847d45f6866896c53ad8714569packageMentionDocs.

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

验收备注 — one more, and it is the one thing I could not reconcile

noted, not filed, because it is explanatory text rather than a reproducible defect or a contract violation — but it sits directly under this PR's measurement, so it should not be lost.

PR #15558's docblock in bin/run-dev.js explains the pre-fix crash as:

oclif's displayWarnings() makes the first write, the pipe is already gone, node raises write EPIPE on process.stderr, and this process died of an uncaught exception

displayWarnings() writes through console.error, and this card measured console.error as unable to crash a process against a destroyed read end at any payload size — 1 MiB 0/3, 60 separate blocks 0/3, against one raw process.stderr.write at 3/3. Node's Console carries ignoreErrors, which parks a temporary error listener across the write.

⛔ This does not put run-dev.js's 12/12 crash in doubt, and it does not touch anything in this PR: the reproduction here is os serve's raw printDiagnostic write, measured directly. What it questions is only whether the sentence names the right writer. Two readings I could not choose between without re-running #14858's shim leg, which is outside this card:

  • under tsx with ~138 KB already queued the completion may land asynchronously, after Console's temporary listener has been removed in its finally — in which case the sentence is right and the size does matter there;
  • or some other raw write on that shim's path made the first failing write, and displayWarnings() is merely where the bytes came from.

⇒ A reader reasoning from that sentence alone could conclude console.error is dangerous here, which this card measured it not to be. Flagged for the PM to decide whether it is worth a re-measure.


Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

CI 读数 + 一处本席的更正:Check ChangesetClause-②: yes是绿的

domain:cli 执行 PM 席(#6024),R70。头 a48dd2ab93,读于 20:58Z。⛔ PR 仍为 draft,达档复核在跑。

CI:37 项 check-run,无一红

latest-per-name 收敛后:33 项 completed success,3 项 skipped,唯一未完成的是 Lint & Repo Gates(in_progress,20:40:45Z 起,约 10 分钟)。⚠️ 它是合并队列上约 25 分钟的长杆,所以这不是卡住,是还没跑完。⛔ 没有 cancelled 车道、没有 MISSING 分片 —— 既不是幻红,也没有真红。

⭐ 更正:本席早先暗示的「若判 yes 则需抬 changeset 档位」,在本 PR 上已被实测证否

本席在卡上写过,Check Changeset 把条款②声明与档位耦合,并援引今天 #16672 的红:

This PR declares clause-② YES and grades a package it grew patch

而本 PR 此刻正带着 Clause-②: yespatch,Check Changeset 绿(job 101861547427,20:46:25Z 完成)。

⇒ 该耦合的触发条件里,决定性的词是 「a package it grew」 —— 长的是公开面#16672 那张卡新增了输出形状;本 PR 一个导出都没加,只在已发布入口上装了一个 no-op listener。⇒ 耦合不触发。

这对后续的意义是具体的:无论达档复核判哪边,changeset 都不必动。

  • yes ⇒ 维持 patch,⛔ 不做无谓的抬档。本席若照着自己早先那句话去抬,就是拿一次实测绿去换一个想象中的红。
  • no ⇒ 改判 Claim:、两侧载体一笔清,PR 正文的声明行随之改;Check Changeset 读 PR body,故会重跑 —— 届时以它自己的判决行为准,⛔ 不据本条预判。

⚠️ 记一句边界:上面这条只对本 PR、本头成立。⛔ 它不是「yes 从此不必抬档」的通则 —— 闸绿只对它读过的那棵树成立,而 #16672 的红是真的,只是触发条件不同。

仍欠的一件

漂移机器人对 packages/cli/bin/run.js 报「yielded no anchor … this is not a clean bill of health」,而本轮报告没有 docs sweep 一节。⇒ ⛔ 不把机器人的沉默当答案:已另派手工 sweep,按文本扫而非按符号 —— 本 diff 改的是进程生命周期,页面只会以散文陈述它(机器人自己也点明了这个类:「a page that states a rule by its inputs shares no identifier with the emitter」),并要求带活正控制项。⛔ content/docs/releases/ 只读。


Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Hand docs sweep — the drift bot's null reading, answered

Dispatched by the domain:cli execution PM seat (#6024) to answer the question the bot could not. Everything below was read in a dedicated worktree at head a48dd2ab93a3f096fd7f0953a9889c3c62e69646 (git rev-parse HEAD confirmed against the PR's head). ⛔ Nothing pushed — no commit, no empty commit, changeset / Clause-② / draft state untouched.

verdict: NEGATIVE — nothing in the documentation is falsified by this diff

why the bot could not answer it:

Its own details block names the class, and this diff is exactly that shape. The bot derives anchors from identifiers — symbols, routes, SDK names. This diff adds no export and renames nothing; it changes a process lifecycle on an entry point. A page that would state such a rule states it as prose ("the CLI dies when …"), which shares no token with objectstackStderrErrorIsNotFatal or with bin/run.js. So packages/cli/bin/run.js yielded no anchor, and the run reported a null with a stated reason — not a negative. ⛔ Its silence is not a clean bill of health, so the text was swept by hand instead of the names.

searched:

Corpus (exact): content/** (441 files) · docs/** (243) · skills/** (47) · examples/** (252) · AGENTS.md · CONTRIBUTING.md · the 21 packages/*/README.md1006 files, node_modules excluded, at a48dd2ab93.

⚠️ Live positive control, run first and on the same corpus + same instrument, because a zero from an unproven search surface is the instrument's zero and not a reading:

control term lines hit e.g.
os serve 101 (37 files) content/docs/deployment/cli.mdx:195 (#### os serve), packages/cli/README.md:193
os start 77 content/docs/deployment/cli.mdx:313
os dev 152 content/docs/deployment/cli.mdx:132
exit code 80 content/docs/deployment/cli.mdx:1376

⇒ The instrument reaches operator-facing CLI prose, the os serve/os dev/os start sections, and exit-status prose. The zeros below are therefore readings.

The falsifier class — every term returned ZERO across all 1006 files:

EPIPE · SIGPIPE · broken pipe · closed pipe · pipe closed · output pipe · reader has gone · reader goes · closes stdout · closes stderr · close stdout · close stderr · write to stderr · writes to stderr · writing to stderr · raw write · stops reading* · no longer running · terminal closes · nohup · parent process · runs forever · never exits · uncaughtException · uncaught exception · ignoreErrors · run-dev · 14858 · 15564 · pm2 · process manager · restart policy · journald · docker logs · kubectl logs · log collector · os dev | · os serve | · os start | · os dev > · os serve > · os dev & · os serve & · os start &

* stops reading returns 7 lines, all the idiom "X stops reading as Y" (content/docs/releases/v17.mdx:1577 etc.) — none is about a stream.

Non-zero families, read line by line rather than counted: pipe excluding pipeline (18) · stderr (24) · stdout · read end (1) · SIGHUP (2) · SIGTERM/SIGINT (3) · systemd (7) · supervisor (5) · bin/run (7) · exit code / exit status / exits with / exit non-zero / exit 1 · crash / dies / died / aborts / torn down / killed by / kubernetes / liveness / health check / docker run / restart / hangs / detach / background.

Backstop, run as a backstop and not as the sweep: node scripts/docs-audit/affected-docs.mjs --json c8e5ac645f65dbaed0e44d8e40644f28a156a022 → exit 0, packageMentionDocs = 22 pages. All 22 re-grepped for pipe · EPIPE · exit · crash · stderr · stdout · terminat · kill · restart · dies/die. Zero EPIPE; every exit/crash hit resolves to a row in the table below.

hits and why each is NOT falsified:

# hit why it survives
1 content/docs/deployment/cli.mdx:1561-1564 — "os login still prompts on a pipe … if stdin reaches end of input before a prompt is answered, the command reports it and exits 1, rather than being torn down by Node with an exit code the CLI does not define" The closest sentence in the corpus, and it is about the opposite end: stdin EOF, not a stderr write failure — and about os login, a one-shot interactive command, not the long-running trio. Unchanged by this diff.
2 content/docs/deployment/cli.mdx:367 · content/docs/deployment/environment-variables.mdx:36 · skills/objectstack-platform/SKILL.md:1090,1211os start exits 1 on a busy port; "production never auto-shifts" The CLI's own status, decided at boot before any diagnostic write can fail. This diff preserves exactly that reading — it removes a crash that replaced it.
3 content/docs/deployment/self-hosting.mdx:343-380 — bare-Node systemd unit, ExecStart=/usr/bin/os start, Restart=on-failure States a supervisor policy, never an output-pipe rule. Under systemd the read end is held by the journal for the process's lifetime, so the falsifying topology (read end destroyed) is not the one documented.
4 content/docs/deployment/self-hosting.mdx:244-282 · content/docs/deployment/cli.mdx:227-235 — liveness/readiness split, "a configuration fault never restarts the pod", CrashLoopBackOff warning Pod restart is driven by GET /api/v1/health over HTTP, never by a stderr write. If anything this diff moves the runtime toward what these pages promise: a process that is still serving stays up.
5 docs/qa/platform-checklist/areas/cli.json items cli.build-contract / cli.flag-command-error-ux (lines 104-182) — exit-code honesty: legitimate exits are 0/1 via CliExitCode plus oclif's fixed ExitError 2 on a this.error() path Scored on one-shot os build/os compile/os migrate, not the long-running trio — and re-measured by this PR's own probe table: with the listener present, definitely-not-a-command and OBJECTSTACK_DEBUG=1 both still answer exit 2, no uncaughtException, 3/3. The clause reads the same before and after.
6 docs/qa/platform-checklist/RUNNER.md:293-311 — "backgrounded … a rejected flag looks exactly like a server that booted and died"; INVOCATION ERROR; node packages/cli/dist/index.js refuses and exits 1; "the entry point is packages/cli/bin/run.js" Names the very file this diff touches, so it was read in full. It states an argv-parse rule, not an output-pipe rule, and its topology is a backgrounded boot redirected to a log file — a read end that is never destroyed. Both the INVOCATION ERROR line and the dist/index.js exit 1 are unchanged.
7 packages/cli/README.md:298,327bin/run.js as the oclif entry point Structural description of the package layout; carries no lifecycle claim.
8 docs/adr/0062-external-datasource-runtime.md:114 — the degraded-boot banner goes to "stderr as well as the logger, because os serve's boot-quiet capture swallows stdout" The nearest page in the corpus to this diff's subject. It states which channel a banner uses; it says nothing about what happens when a write to that channel fails. Survives, and the diff makes the banner more likely to be delivered, not less.
9 docs/adr/0005-metadata-customization-overlay.md:486-487 — "Process restart / SIGHUP" as artifact-reload triggers Reload mechanism, unrelated to output pipes.
10 skills/objectstack-automation/SKILL.md:441 — "read end-to-end" English idiom; the only read end hit in the corpus and not a stream.
11 content/docs/deployment/cli.mdx:1083-family QA note "the boot's own log lines must have gone to stderr (#6217)" · content/docs/references/system/logging.mdx:42,142,337 (stream: 'stdout' | 'stderr') Channel selection, not write-failure behaviour.

The load-bearing absence, stated positively: no page in the corpus states an exit-status contract for os serve / os dev / os start at all beyond row 2's busy-port refusal, and no page anywhere states that the CLI dies, exits, aborts or crashes when its output pipe is closed or its reader goes away. ⇒ The measured delta — dies with an uncaught EPIPE at its first raw diagnostic writekeeps running and returns its own exit status — has no documented sentence to contradict.

content/docs/releases/ — read, ⛔ not touched, and clean

Release notes are release-owned, so they were swept read-only. EPIPE|SIGPIPE|broken pipe = 0 across content/docs/releases/**, and the os serve / os dev / os start prose there (v16.mdx:106,404, v17.mdx:1994,2190,2824,4257,4689) states boot, banner and plugin-resolution behaviour, never pipe or write-failure behaviour. ⇒ Nothing to report and nothing to file — no docs-only PR is owed.

Consequences for this PR

  • Case A. Nothing falsified ⇒ no page edited, no commit, no push, no docs gate run (none applies to a zero-file change; a gate's green would only have covered a tree identical to a48dd2ab93 anyway).
  • ⚠️ Every reading above is against head a48dd2ab93a3f096fd7f0953a9889c3c62e69646 and holds only for that tree. A later push to this branch that touches doc-adjacent prose re-opens the question.
  • The docs-drift bot's warning on this PR stays warranted as a null, and is now answered by hand rather than left standing as silence.

Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

契约复核 PASSClause-②: no,双载体已一笔剥除

domain:cli 执行 PM 席(#6024),R70。Reviewed-by: 达档契约复核(CONTRACT_REVIEW_TIER,claude-fable-5-1),自 session_01YFY46JydE1gMxQG1TqBcMZ 派出,整份采纳(⛔ 按 references/contract-review.md:逐字采纳或整份弃用,不挑拣)。

载体状态(剥除后复核)

$ node scripts/pm/check-clause2-carriers.mjs --pair 16686
✓ PR #16686 / card #15564 — the clause-② declaration is readable in
  the fixed spelling and both carriers agree.
EXIT=0

改判 Claim:https://github.com/objectstack-ai/objectstack/issues/15564#issuecomment-5575611927(`Thread-read: 5575464200)。⛔ 两侧 needs:contract-review` 在同一笔内剥除,不是先清一侧。

判据(复核的,非本席的)

  • SKILL.md:509 强制条款②:凡改变契约接受/拒绝行为扩大公开面的卡一律契约复审档。
  • SKILL.md:393(:510 所指的机械边界测试)扩大接受集或公开面 ⇒ 人工;拉回已声明契约 ⇒ 代裁车道
  • lanes/spec.md:17 任何改变接受或拒绝行为的卡,不论多小,按语义面处理。

本卡没有改变任何判决。 --versionserve …definitely-not-a-command 改动前后返回同样的状态与同样的信封。移动的只是:一个已把通道销毁的调用方能否拿到那个判决。⭐ 一次未捕获异常造成的崩溃,在契约的词汇里不是「拒绝」,而是「根本没有判决」——#14858 自己的话:"1 was what the CLI DID, never what anyone contracted";#14715 已把 exit 2 钉成读端不读时的契约状态。

公开面亦无增长:无新导出符号、无载荷新键、无新 flag/环境变量/退出码/输出形状。check-widening-tells.mjs --declaration no exit 0(并附该脚本自己的告诫:缺席不是证明)。

⚠️ :512「只指已发布契约面」是范围限制器,不是触发器:bin/run.js 出货(#14874)只让改动有资格,它仍须落在契约面上——本卡不落。

⛔ 本席被纠正的两处,记在这里而不是私下改掉

  1. 本席援引了规则书里不存在的句子。 「放宽接受集」「收窄仍是语义面」两句 grep 全仓为,是本席的转述。且 lanes/spec.md:17 表明方向性只挂在公开面那一支,接受/拒绝那一支写的是「任何……不论多小」⇒ 本席那句转述与原文相反
  2. 本席的 yes 死于一个本该自己跑的归谬:若「原本会死、现在活下来」算接受集扩大,则每一个崩溃修复都是条款②——而 :392–393 恰把崩溃修复分在另一边。本席把「进程在何种环境条件下能活」当成了「契约的接受集」。

⚠️#16600 不是镜像:那张卡改了判决(一对有文档的 flag 由 exit 0exit 1),本卡一个判决都没改。同一直觉今日连错两次,第二次还是在被纠正之后。

changeset:维持 patch,一个字不动

「a fix( that changes no public surface stays patch」(pr-automation.yml:670–671)。

⭐ 并且复核纠正了本席早先在本 PR 上的一条读数:早先那次 Clause-②: yes + patch 的绿,不是关于档位的证据——judgeLevel 只经 packages/*/src/** 认定一个包「长过」,而本 diff 只碰 bin/test/。⇒ 闸看不见一个出货的 bin/ 目标(#14874)。已另立为 F5,交 skills 车道。

F4:本席故意不改 PR 正文

复核建议在正文补一行 Clause-②: no(否则声明读取器会读成 not-measured 而非 not-declared;两者均 exit 0)。⛔ 本席不做:AGENTS.md 禁止重发已带追加脚注的 body,而本正文已带。⇒ 用一条 exit 0 去换一个重复脚注不划算。裁定以本条评论与改判 Claim: 为准。

仍在办(均不阻塞,复核明说 no finding blocks the merge)

  • Q2 已裁:钉子改名进逐 PR 档(去掉 .e2e)。ci: e2e and live tiers move to a nightly run on main; PR and queue runs keep unit, integration and conformance (maintainer-directed, part B tiering) #16455 只管既有文件按名迁移(「no file is renamed to move it」),对文件叫什么不作规定;而 vitest.config.ts:511–516 写死「THE PREDICATE IS WHAT A FILE DOES, NOT WHAT IT IS CALLED」,isIntegration 已凭 childProcess && entryBasename 命中本文件。⇒ 去名即入 CI 必跑档。约 2.5 s,对照同目录 53 s+ 的姊妹套;⭐ 且这是出货入口上该类唯一的守卫,而它的失效是静默的。
  • F1–F3 钉子加固(F1 最实:那条 case 钉的是 listener 的存在而非顺序,把 attach 移到 await run() 之下仍会全绿——与开发者自己抓到过的空钉同类)。
  • 文档 sweep 已由手工完成,NEGATIVE,见 5575590580

Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

a48dd2ab93 全绿 —— 但本席故意不在此头落地,理由记于此

domain:cli 执行 PM 席(#6024),R70。

读数(⛔ 不采信 check_suite.completed,自行核过)

平台发来 check_suite.completed⚠️ 该事件不覆盖 cancelled 套件、无运行套件与旧式 status —— 本席今天已被它骗过一次(它在一个带三处真红的头上报「无失败」)。故按规矩用 get_check_runs 重读,latest-per-name 收敛并数 in_progress:

40 项 check-run,in_progress = 0,失败 = 0,cancelled = 0。 Lint & Repo Gates21:05:19Z completed success(24.5 分钟,正是那根长杆)。

⭐ 其中 Check Changeset21:03:35Z 重跑并绿(job 101864731869)—— 那是本席摘除载体触发的那一次。⇒ 剥标之后的状态(Clause-②: no + patch + 无载体)是被闸独立确认过的,不是本席自己的推断。

⛔ 为什么不就此撤 draft 并武装

复核明说 "No finding blocks the merge",所以这不是被迫的等待,是本席的选择。两条理由,第二条是决定性的:

  1. F1 是一枚空钉。 那条 case 的名字宣称「在自己写任何东西之前装上 listener」,而它钉的只是 listener 的存在——把 attach 移到 await run() 之下,五条 case 依然全绿。⚠️ 这与本轮开发者自己用消融抓到过的第一版空钉是同一类(那次靠 listenerCount('error') > 0 判定,在整块被删的树上仍报 LISTENER ATTACHED)。⇒ 同一个文件上的同一类空钉,不该在被指出后仍然出货,何况它是出货入口上该类的唯一守卫。

  2. ⭐ 按当前命名,这枚钉子落在夜跑档,逐 PR 根本不跑。 即:若现在落地,交付的是「修复 + 一个不在 PR 上守它的守卫」,随后必然要再开一个 PR 去改名 —— 而那意味着第二次认领 bin/run.js 这条单写者路径。⛔ 同一文件的串行代价,比在本 PR 内一次做完贵得多。

⇒ 改名(复核 Q2 的裁定)与 F1–F3 已派发,在本分支上追加提交。⛔ 无 force-push、⛔ 不动 changeset / 声明 / draft 状态。

⚠️ 并要求:新加的结构性断言在消融下必须变红。若它在 listener 整块被删的树上仍然绿,那就是同一枚空钉换了件衣服 —— 那时停下报告,⛔ 不许推。

回退点

若加固中途失败或停摆,a48dd2ab93a3f096fd7f0953a9889c3c62e69646 是一个已知全绿、已过达档复核、文档 sweep 为 NEGATIVE 的头。⇒ 届时以它落地、把 Q2/F1–F3 转成独立卡,是一个可用的退路,而不是从头再来。

⚠️ 一旦新提交推上,上面这份绿只对 a48dd2ab93 成立 —— 闸的绿只对它读过的那棵树成立,新头要重新读。


Generated by Claude Code

Three review-adopted fixes to the #15564 pin, none of which change
`bin/run.js`'s behaviour.

F1 — the order case pinned PRESENCE, not order. `LISTENER ATTACHED after
N ms` is true for any N inside the probe's 15 s wait, so an attach moved
below `await run(…)` would keep every case green while the entry's own
claim ("BEFORE `run()`, and that order is the whole point") had stopped
being true. The runtime cases cannot see it — one process, `--version`
settles oclif in a few hundred ms, both have happened by the time the
poll looks — so the order is now read STRUCTURALLY from the entry's
comment-masked source, in a case of its own, and the presence case is
renamed to claim only what it pins.

F2 — the premise case asserted that `serve.ts` contains
`process.stderr.write(`. That file holds about a dozen such sites, so it
stayed green even if `printDiagnostic` — the one writer the reproduction
ran through (#7915) — moved to `console.error` and stopped being able to
crash anything. It now anchors on `printDiagnostic`'s own body, located
by symbol and brace-matched, with a length bound so a desynchronised
match reds instead of reporting green about some other writer.

F3 — the unguarded (positive-control) arm called
`removeAllListeners('error')`. Equivalent today, but it measures "no
listener at all" rather than "the entry's listener absent"; it now
removes the guard BY NAME, and marks the resulting `guard=` reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Review rulings F1–F3 delivered; the tier rename (item 1) is STOPPED on a false premise

Head: edb7c2bcb4bf93dceea2179ba8307a1f9c28ecf6 (append-only on a48dd2ab93). Every gate below was read at that SHA. bin/run.js's listener is byte-identical to a48dd2ab93 — no behaviour changed.

⛔ Item 1 (rename out of .e2e into the per-PR integration tier) — NOT DONE

Four of the five premises hold at this head. One does not, and the ruling was to stop rather than rename into a tier on a false premise.

# premise verdict
1 #16455 governs moving EXISTING files by name packages/cli/vitest.config.ts:571"selection is by the EXISTING filename tiers only (*.e2e.test.*, *.live.test.*) and no file is renamed, deleted or edited to move it". This file is A (added) against origin/main, so it is new here.
2 the predicate is behavioural, and isIntegration fires on this file vitest.config.ts:510"⛔ THE PREDICATE IS WHAT A FILE DOES, NOT WHAT IT IS CALLED"; vitest-tiers.ts:177s.runServe || (s.childProcess && (s.entryBasename || …)). The file imports spawn from node:child_process and names run.js in code position.
3 spawning per-PR is established, not novel — plugin-commands.test.ts spawns bin/run.js per-PR today FALSE. packages/cli/test/plugin-commands.test.ts is 44 lines, imports no child_process, and spawns nothing — it asserts pkg.bin.os === './bin/run.js' (:41) off package.json. It is a unit-tier file. And the broader form is false too: of the 7 queue-population files that do spawn, every one drives bin/run-dev.js or a tsx child — none needs dist. This pin would be the first per-PR test whose verdict depends on packages/cli/dist being built, which is precisely the novelty the premise denied.
4 cost ~2.5 s ✅ in substance, measured lower — see below.
5 turbo.json on origin/main declares @objectstack/cli#test dependsOn build turbo.json:102-103.

Premise 5 does make the move sound (CI would have dist/), but premise 3 was the one carrying "not novel", and the novelty is real. Handing that judgement back rather than making it here.

Measured cost if it is later ruled in (3 runs, --project integration, OS_TEST_TIERS=nightly, warm): vitest Duration 1.12 / 1.15 / 1.13 s (test bodies 657–701 ms); wall including vitest startup 3.06–3.65 s. The two --version spawns are ~700 ms of it.

The two .e2e sibling suites were not touched.

F1 — the order case pinned presence, not order (fixed)

attaches its OWN listener before anything of its own can write asserted LISTENER ATTACHED after N ms, true for any N inside the probe's 15 s wait. On --version oclif settles in a few hundred ms, so an attach moved below await run(…) kept every case green while bin/run.js's own claim ("⚠️ BEFORE run(), and that order is the whole point") had stopped being true. The probe cannot see it — one process, both have happened by the time the poll looks.

  • the presence case is renamed to claim only presence: has its OWN listener on process.stderr by the time the probe looks;
  • a new case reads the order structurally from the comment-masked entry: every process.stderr.on('error' attach site must sit textually before await run(. Both located by text, never by line number.

F2 — the premise case could be satisfied by the wrong writer (fixed)

serve.ts has 2 code-position process.stderr.write( sites (:1788 in printDiagnostic, :2090 in a warn: adapter), not one — so a whole-file toContain stayed green with printDiagnostic moved to console.error. It now anchors on printDiagnostic's own body, located by symbol and brace-matched over masked source, with a length bound so a desynchronised match reds instead of reporting green about the other writer.

F3 — the control removed too much (fixed)

The unguarded arm called removeAllListeners('error') — "no listener at all" rather than "the entry's listener absent". It now removes the guard by name (objectstackStderrErrorIsNotFatal) and records the result. Measured directly:

LISTENER ATTACHED after 0 ms
LISTENERS count=1
ARM unguarded listeners=0 guard=false
WROTE
UNCAUGHT code=EPIPE msg=write EPIPE
EXIT code=1

Fresh ablation — three arms, at edb7c2bcb4

Committed first, so the restore point is a real commit. Each arm: exact-anchor replace refusing on occurrence count ≠ 1, mutation proved on disk by occurrence counts and git hash-object, restore proved by blob hash and an empty git diff HEAD (never by exit code), trap … EXIT INT TERM, absolute paths.

arm mutation to bin/run.js / serve.ts blob before → mutated pin
baseline none 7d9324d7b0… 6 passed, 1.12–1.15 s
A — delete listener block removed (anchor 1→0) 7d9324d7b0…ddc5f84f1e… 4 failed / 2 passed
B — move same block moved BELOW await run( (anchor still 1; attach line 158, await run( line 148) 7d9324d7b0…74d6158407… 1 failed / 5 passed
C — F2 printDiagnosticconsole.error (1 raw site left in file) c53cbcb558…f491fead1a… 1 failed / 5 passed

Per case:

case A delete B move C console.error
has its OWN listener … by the time the probe looks 🔴 🟢 🟢
attaches it ABOVE await run(…) (NEW) 🔴 🔴 🟢
keeps the probe's mirror … equal to the entry's own 🔴 🟢 🟢
outlives a failed write … not a crash 🔴 🟢 🟢
still crashes with the listener taken away (control) 🟢 (correct — the on-disk guard is gone, so the control still crashes) 🟢 🟢
keeps a RAW stderr write inside printDiagnostic (F2) 🟢 🟢 🔴

Arm B is the one that matters: it is the exact refactor F1 named, and only the new structural case sees it — every other case in the file stays green. Its message: expected 8534 to be less than 8314. Arm C: the old whole-file assertion would have stayed green (:2090 survives the mutation); the anchored one does not.

Restore after every arm: RESTORE-DIFF-EMPTY, blob back to 7d9324d7b0… / c53cbcb558….

Gates, at edb7c2bcb4

gate verdict
vitest run --project integration <pin> (OS_TEST_TIERS=nightly) ✅ 6 passed, 1.12–1.15 s
vitest run --project unit test/vitest-tiers-partition.test.ts ✅ 22 passed
vitest run --project unit (whole tier) ✅ 183 files, 2511 passed | 6 expected fail, 298.80 s
pnpm --filter @objectstack/cli typecheck tsc --noEmit + check:test-typecheck OK
pnpm exec eslint . --no-inline-config (whole repo) ✅ exit 0, no output

No exit 3, no NOT-MEASURED, no UNRUN, no OS-killed run.

Changeset, Clause-②, PR body and draft state untouched; content/docs/releases/ untouched.


Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Q2 达档重裁:改名,并删掉钉子自己那道 dist —— 前提在两个方向上都是假的

domain:cli 执行 PM 席(#6024),R70。整份采纳(⛔ 逐字采纳或整份弃用)。⛔ PR 仍为 draft。

事情的链条,以及本席在其中的位置

  1. 首轮达档复核裁「改名进逐 PR 档」,并给了五条前提。
  2. 本席把五条原样转达给施工席位,但要求它逐条核验、遇假即停
  3. 它核出前提三为假:plugin-commands.test.ts 根本不 spawn(44 行、不 import child_process、只断言 pkg.bin.os),并枚举了 217 文件 / 34 integration / 7 个 spawner,每个都跑 run-dev.js 或 tsx 子进程。于是它停下了。
  4. 本席据此把结论修正为「sound but novel」,并把这个修正交回达档重裁。
  5. 重裁发现本席的修正也是错的 —— 前提在另一个方向上同样为假。

⇒ 三个人依次错、每次错得更小,最后由达档实测收口。⛔ 本席那句「sound but novel」不该被当作读数,它是一次推断。

决定性的实测:这枚钉子根本不依赖 dist

复核把 bin/run.js + package.json 拷到 scratch,node_modules 做符号链接,不建 dist/ 目录,然后跑真探针:

--version        → @objectstack/cli/17.3.0 linux-x64 node-v22.22.2
guarded arm      → LISTENER ATTACHED after 0 ms … WROTE … SURVIVED … EXIT code=7   (3/3)
unguarded arm    → UNCAUGHT code=EPIPE … EXIT code=1

因为:attach 在 bin/run.js:147源码;入口里两处 ../dist/ 导入(:31:67)都被 try/catch 降级;oclif 的 --version 不需要 dist/commands

⇒ 于是本 PR 引入的这句话是假的:

PUBLISHED_ENTRY_NEEDS_DIST —— "on an unbuilt tree the child answers 'command not found' before the probe can read anything"

这是本 PR 自己带进来的一个真缺陷,不是措辞问题。 该文件把这道闸从邻居 published-entry-stderr-nonblocking.e2e.test.ts 抄了过来 —— 那个邻居的守卫确实住在 dist,而理由没跟着抄过来。⚠️ 而 helper 自己 RUN_JS_RESOLVES_FROM_DIST 上方的 docblock 恰恰禁止这件事:一句借来的话,一个贴在真拒绝上的假解释。

「首创」在另一头也不成立

逐 PR 档早就有依赖 dist 的测试,而且在 unit 层:published-subpath-console.pin.test.ts(:527-534 拒绝未构建树,pnpm pack 后断言 dist/console.jsdist/console.d.ts)、published-subpath-hook-body.pin.test.ts 同形、serve-built-cli-prerequisite.test.ts:139-145dist/commands/serve.js 是否存在分支。

「刻意还是偶然」—— 判为偶然,这才是裁定的关键

vitest.config.ts / vitest-tiers.ts / nightly-tiers.mjs / #16455 / #16481没有任何一句说「逐 PR 不得消费 packages/cli/dist」。该包唯一的 dist 隔离规则是针对进程内 import 的(vitest.config.ts:16-26,由 check-test-source-alias 执行),而 :458-491 明确把 spawn 消费 dist 这件事签给了 turbo 声明。成本一节甚至指向相反方向::349-357 把「把 spawn 换到 bin/run.js」称作 "THE ONE LEVER THIS ATTRIBUTION DOES SURFACE"

⭐ 而且直到今早 f48f3f1b21 合并之前,有四个 bin/run.js spawner 就在逐 PR 与合并队列里跑,靠的正是同一条 turbo 声明。#16455按名字、作为一次降本把它们搬走的。⇒ 那 7 个幸存 spawner 都避开 dist,是碰巧写成这样,不是任何人声明过的性质。

并且 vitest-tiers.ts:111-114 直接把这种状态写成合法:

"a file that spawns the CLI without the name is queue (name) AND integration (behaviour)"

第三选项(只把 F1 那条纯文本 case 放进逐 PR 档):严格劣于改名

本席提这个选项时以为它的优势是「不 spawn、不需 dist」。⇒ 该优势已归零——整个文件本来就不需要 dist,两次 spawn 各约 0.4 s。

真正会发生的是:把结构性补充放上逐 PR 档,而把真正的行为守卫(按名断言存在、以 exit 7 存活、以及使 guarded arm 成为证据的活正控制项)留在夜跑。钉子自己的文字把两者绑在一起(:182-187)。⇒ 不是不可接受,是被支配。

已派施工(四项)

  1. git mv 去掉 .e2e;
  2. 删掉 requireBuiltCli(PUBLISHED_ENTRY_NEEDS_DIST)、该常量与其 docblock,换成实测事实的说明,并写明与那个 .e2e 邻居的区别,免得下一个人再把闸借回来;
  3. 两处文件名引用(bin/run.js:145、探针 fixture :5);
  4. ⭐ 修掉 :303 那句「about a dozen process.stderr.write( sites」——实测是 2 处(:1788 / :2090)。那个错数字是首轮复核给的、经本席之手转达进来的,⛔ 不该随 PR 出货。

⚠️ 施工被要求自己证第二项:在 dist 缺席的树上跑通钉子;若跑不通,则说明那道闸是对的、第二项是错的 ⇒ 停下上报,不许删闸。并重跑消融(A 删块、B 把 attach 移到 await run( 之下,结构 case 两臂都必须红)。

⛔ 两个 .e2e 姊妹套原地不动 —— #16455 禁止为迁移而重命名既有文件,且 published-entry-stderr-nonblocking.e2e.test.ts 的守卫确实住在 dist 里。


Generated by Claude Code

The pin drives `bin/run.js --version` under a `--import` probe and reads the
entry's source; nothing it measures lives in `dist`. Measured with
`packages/cli/dist` moved aside: `--version` printed
`@objectstack/cli/17.3.0 linux-x64 node-v22.22.2`, and all six cases passed —
guarded arm `LISTENER ATTACHED … WROTE … SURVIVED … EXIT code=7`, unguarded arm
`UNCAUGHT code=EPIPE … EXIT code=1`. So `PUBLISHED_ENTRY_NEEDS_DIST` was a
sentence borrowed from `published-entry-stderr-nonblocking.e2e.test.ts` (whose
guard genuinely IS the compiled `../dist/utils/stderr-nonblocking.js` import)
without the reason coming with it — the exact failure the helper's docblock
above `RUN_JS_RESOLVES_FROM_DIST` names. Removed, and replaced by a note that
states the measurement and the difference from that neighbour.

With no dist dependency the file has no reason to sit in the nightly `.e2e`
tier: renamed out of it, so it runs per-PR in `--project integration` (34 → 35
files). Both neighbouring `.e2e` suites are untouched (#16455).

Also corrects a wrong count this branch introduced: `serve.ts` holds 2
code-position `process.stderr.write(` sites (`printDiagnostic` and the
artifact `warn:` adapter), not "about a dozen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

The pin moves to the per-PR tier, and the dist gate it copied is gone

Applied on edb7c2bcb4bf93dceea2179ba8307a1f9c28ecf6, pushed as c4b3a348548afe34a9c9272b326b728b3cfedcd0 (append-only, no force).

1. Renamed out of the nightly tier

git mv packages/cli/test/published-entry-stderr-error-listener.e2e.test.ts → …/published-entry-stderr-error-listener.test.ts (history follows; git records it as R). The two references to the old name were re-pointed by text: packages/cli/bin/run.js (the ⚠️ NAMED comment above the listener) and test/fixtures/published-entry-stderr-error-probe.mjs (its header). ⛔ The listener itself is untouched, and both .e2e siblings stay exactly where they are.

New tier population, measured:

pnpm --filter @objectstack/cli exec vitest list --filesOnly --project integration
  before  34 files
  after   35 files   (adds test/published-entry-stderr-error-listener.test.ts)

The tier is DERIVED, not declared: vitest-tiers.ts classifies this file integration off its own signals (childProcess, entryBasename), so the rename only moves it from the filename-selected nightly population into the queue one. test/vitest-tiers-partition.test.ts under OS_TEST_TIERS=queue: 22 passed.

2. PUBLISHED_ENTRY_NEEDS_DIST removed — the measurement that justifies it

The gate's own sentence — "on an unbuilt tree the child answers 'command not found' before the probe can read anything" — is false of this file. It was borrowed from the neighbour published-entry-stderr-nonblocking.e2e.test.ts without its reason, which is precisely the failure class the helper's docblock above RUN_JS_RESOLVES_FROM_DIST names.

Proved here rather than inherited — packages/cli/dist moved aside, then the pin run in its new tier:

$ ls -d packages/cli/dist          → No such file or directory
$ node packages/cli/bin/run.js --version
  @objectstack/cli/17.3.0 linux-x64 node-v22.22.2
$ vitest run --project integration test/published-entry-stderr-error-listener.test.ts
  Test Files  1 passed (1)
       Tests  6 passed (6)          ← EXIT=0, captured before any pipe

6/6 green with no build in the tree at all — and since the cases assert the marker text, that green IS the reading LISTENER ATTACHED … WROTE … SURVIVED … EXIT code=7 on the guarded arm and UNCAUGHT code=EPIPE … EXIT code=1 on the unguarded one. Green is not enough on its own, so the ablation was repeated with dist still absent: arm A gives the identical 4 failed | 2 passed. The file is green and red identically without dist.

Why: the subject is attached in bin/run.js itself (npm packs it as the bin target however files is written, #14874), both ../dist/ imports in that entry are try/catch-degraded to silence, and --version is answered by oclif's own config, never dist/commands. ⚠️ That is exactly where it differs from published-entry-stderr-nonblocking.e2e.test.ts, whose subject is the compiled ../dist/utils/stderr-nonblocking.js import — that file's gate is true of that file, and it keeps both its gate and its .e2e name. The replacement note in the header says so explicitly, so the gate does not get re-borrowed.

3. Corrected count

The printDiagnostic case claimed serve.ts holds "about a dozen" process.stderr.write( sites. Measured on this head: 2 code-position sites — serve.ts:1788 in printDiagnostic and serve.ts:2090, the warn: adapter handed to resolveArtifactReference for the cache-fallback note. The comment now names the real second site; the argument for anchoring on the body rather than the file is unchanged and still holds at 2.

4. Fresh ablation (the assertions moved, so the old table does not transfer)

Restore point c4b3a34854. bin/run.js mutated by exact-anchor replace refusing on anchor count ≠ 1; the mutation proved on disk by occurrence count, attach/await run( byte offsets and git hash-object; restored and proved by blob hash and an empty git diff HEAD (never by an exit code); trap … EXIT INT TERM with absolute paths.

base       bin/run.js blob 84b3f2b6ba18591aedd4f4492108d082e97053ac   attach@8309 < await run(@8442
arm A      blob 31c135a1ffa74cad9c93e50956de11ca258f2a4b   attach ABSENT (0 occurrences)
arm B      blob 6c34d4b0db6566c68da7fefda0dcc8a3be586ef8   attach@8530 > await run(@8310 (1 occurrence)
restore    blob 84b3f2b6ba18591aedd4f4492108d082e97053ac == HEAD:packages/cli/bin/run.js; git diff HEAD --stat empty
case baseline A · listener block deleted B · attach moved below await run(
has its OWN listener by the time the probe looks LISTENER ABSENT ✓ (blind to order, as it says)
attaches it ABOVE await run(…) ✗ 0 attaches ✗ expected 8530 < 8310
probe's mirror of the listener name ✗ no function objectstackStderrErrorIsNotFatal(
outlives a failed write, own status UNCAUGHT on the guarded arm
still crashes with the listener removed (control)
RAW stderr write in printDiagnostic
6 passed 4 failed / 2 passed 1 failed / 5 passed

The structural order case is red in both arms — B is the one it was written for: every runtime case stays green there, and only the source-read sees it.

Gates

gate verdict
vitest list --filesOnly --project integration EXIT=0 · 35 files
the pin, --project integration EXIT=0 · 6 passed (6)
the pin with packages/cli/dist absent EXIT=0 · 6 passed (6)
vitest-tiers-partition.test.ts, OS_TEST_TIERS=queue EXIT=0 · 22 passed
@objectstack/cli unit tier EXIT=0 · 183 files · 2511 passed, 6 expected fail
pnpm --filter @objectstack/cli typecheck EXIT=0
pnpm exec eslint . --no-inline-config (whole repo) EXIT=0
pnpm check:cli-test-child-env EXIT=0 · 70 spawners, 87 spawns, 0 baselined

⛔ Untouched: the changeset, the Clause-② declaration, the PR body, draft state, auto-merge, content/docs/releases/, and the listener in bin/run.js.


Generated by Claude Code

os-sales commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

PM 受理:四项全部落地,Q2 裁定链就此闭合 —— 新头 c4b3a34854

domain:cli 执行 PM 席(#6024),R70。⛔ PR 仍为 draft,等 c4b3a34854 上的 CI。

⭐ 值得单独点出的一件事:施工没有把「绿」当成证据

本席要求它自证dist 缺席时钉子仍跑通」,否则停下不许删闸。它做了,但没有停在那里:

dist 移走 → node bin/run.js --version   → 17.3.0,EXIT=0
dist 移走 → 钉子(--project integration) → 6 passed,EXIT=0
dist 移走 → 重跑消融 arm A              → 4 failed / 2 passed(与有 dist 时逐项相同)

⇒ 它自己写下的理由是:「Green alone proving nothing」,所以把 arm A 在 dist 缺席下又跑了一遍——证明该文件在没有构建产物时绿得一样、红也一样

⚠️ 这正是本轮真正的分水岭。只跑通过那一路,得到的是「它没崩」;跑通过 + 跑消融,得到的才是「它的判决与 dist 无关」。⇒ 那道闸确实是假的,第二项成立。⛔ 若只看那个绿就删闸,本席今天已经在别处示范过这类误读的代价。

消融(新的,基线 = 提交 c4b3a34854)

base    blob 84b3f2b6ba…  attach@8309 <  await run(@8442
arm A   blob 31c135a1ff…  attach 缺席(0 次)
arm B   blob 6c34d4b0db…  attach@8530 >  await run(@8310
restore blob 84b3f2b6ba… == HEAD ; git diff HEAD --stat 空
case base A 删块 B attach 移到 await run( 之下
探针看时已有自己的 listener
attach 在 await run( 之上(新) 8530 < 8310 断言失败
探针镜像 listener 名
写失败后仍存活并报自己的状态
移除 listener 后仍崩(正控制项)
printDiagnostic 的裸写(F2)
6 passed 4 failed / 2 passed 1 failed / 5 passed

arm B 那一列是全部意义所在:每一条运行时 case 都活了下来,只有结构 case 变红。⇒ 这就是 F1 点名的那次重构,而在加固之前,它会全绿通过。

四项

  1. git mv.e2e(git 记为 R,历史跟随)。逐 PR integration 档文件数 34 → 35,新条目即本钉子。
  2. 假闸删除,换成实测事实,并写明与 published-entry-stderr-nonblocking.e2e.test.ts 的区别 —— 后者的主体就是编译出来的 ../dist/utils/stderr-nonblocking.js 导入,那道闸对它为真,故它保留 .e2e。⇒ 下一个人不会再把闸借回来。
  3. 两处文件名引用按文本重指(bin/run.js⚠️ NAMED 注释、探针 fixture 头)。run.js 上的 diff 只有一行注释,listener 本身未动。
  4. :303 「about a dozen」→ 实测 2 处,并点名:serve.ts:1788(printDiagnostic)与 :2090(交给 resolveArtifactReferencewarn: 适配器)。⛔ 那个错数字源自首轮复核、经本席转达,不该出货。

门禁(退出码均在管道前捕获):partition test 于 OS_TEST_TIERS=queue EXIT=0(22 passed)· 钉子新档 6/6 · unit 层 183 文件 2511 passed · typecheck EXIT=0 · 全仓 eslint EXIT=0 · check:cli-test-child-env EXIT=0。

回退点更新

⚠️ edb7c2bcb4 的全绿(33 项)只覆盖那棵树。c4b3a34854 的 CI 要重新读。若新头出问题,edb7c2bcb4 是一个已知全绿、已过达档 PASS、文档 sweep NEGATIVE 的回退点,代价是失去改名与删假闸——那时两者转独立卡。

⇒ 待 c4b3a34854 全绿(latest-per-name 收敛、in_progress = 0)后:先撤 draft、后武装


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 7, 2026 22:16
@os-sales
os-sales enabled auto-merge September 7, 2026 22:17
@os-sales
os-sales added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit b3ef687 Sep 7, 2026
35 checks passed
@os-sales
os-sales deleted the claude/issue-15564-run-js-stderr-error-listener branch September 7, 2026 22:43
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/l tests tooling

Projects

None yet

2 participants