Repository navigation
fix(cli): the os dev watcher prints why a recompile was refused - #23867
Conversation
The watch-recompile spawn of `compile` discarded stdout, where compile renders its refusals, so a refused save printed a bare "compile failed". Capture both streams and print them on a non-zero exit. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…stdout Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…v-watch-compile-reason
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not 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
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin bb778d30b352cef9853c33fa9951ed8a9dd4d46f && git checkout bb778d30b352cef9853c33fa9951ed8a9dd4d46f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7e836da1ed344aaa6936b9de8b173d0f326b9ec 4f4f529e91dea86ca821f3aab00e7fe74f49ee81 && git checkout -B drift-repro f7e836da1ed344aaa6936b9de8b173d0f326b9ec && git merge --no-ff 4f4f529e91dea86ca821f3aab00e7fe74f49ee81
node scripts/docs-audit/affected-docs.mjs --json f7e836da1ed344aaa6936b9de8b173d0f326b9ec |
席位核对ACCEPT · #23820 ·
Landing: once every check on the head is green or an expected skip, this seat flips the PR ready and arms auto-merge through the relay, then follows it to Generated by Claude Code |
Fixes #23820
Clause-②: no
What changed
os dev's watch-recompile loop (compileAndPinginpackages/cli/src/commands/dev.ts) now captures thecompilechild's stdout as well as its stderr. On a non-zero exit it prints both under the✗ compile failedline: the same linespnpm buildprints, with no--verboseneeded. Nothing else changes:--verbosestill passes both streams straight through, so a failure prints nothing twice.Measured: why the refusal never appeared
Setup: a fresh
os init my-app -t app --no-installscaffold (namespacemy_app), made with the CLI built from this branch. Dependencies are18.0.0-next.2from npm. An object namedtaskis wired intosrc/objects.os compileexits 2. It writes 10 lines (517 bytes) to stdout and 0 bytes to stderr, so the refusal is on stdout.pnpm buildprints the same 10 lines and exits 2.os dev --fresh -p RANDOMboots, the server answers, and the object is saved. Before (base1eac7ad751):After (this branch, same scaffold, same save):
Both runs were torn down by their own PID, and the port had no listener afterwards.
Where the fix lives: the watcher (A), not the producer (B)
compilewrites to stdout on purpose. It renders every text-face refusal throughprintError, which writes to stdout: 12 call sites incompile.ts, and the printer is used by 61 command files.packages/cli/src/utils/format.tsstates this as the convention.printErrorToStderrexists only for shared helpers that cannot see--json, and "a command that has already decided it is rendering the text face should keep writing to stdout".compilealone or for every command. It also changes the streams every other caller sees:os dev's boot compile,os startandos environments bindspawncompilewith inherited streams. The e2e suites that spawn it would see it too.What is printed
◆ Compile, the rule and→ Loading configuration.... The other 6 lines are the refusal, which has two paragraphs. The noise is small, and the last progress line also tells you which step refused.dev.tsparsingcompile's glyphs. That is a second reader of another command's prose, and it would silently drop output whenever that prose changed. So it is not done.maxBufferis now 64 MiB. spawnSync's default is 1 MiB, and a child whose pipe fills the buffer is killed. With stdout now piped, a large but successful compile would have turned into a failed one. For scale, the successful compile on the scaffold writes 736 bytes.Surface
dev.tscompileAndPingonly (itsspawnSyncoptions and its failure line), adds one pin and one changeset.compile.tsand every refusal producer. No other region ofdev.ts: theresolveDevDatabaseand database-directorymkdirSyncregions belong to runtime/cli: os start on an OS_DATABASE_URL sqlite file whose directory cannot be made hangs with no timeout: createStandaloneStack's synchronous recursive mkdirSync loops on ENOENT under an existing parent (the #23696 class, a second producer) #23737 (seat 1's PR fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851), and this PR leaves them alone.Tests
Pin.
packages/cli/src/commands/dev-watch-compile-refusal.pin.test.ts, unit tier: it spawns a fixture, never the CLI entry, and boots no kernel.How it is built:
startWatchRecompileandcompileAndPing. It fakes only chokidar, plus a stub for./serve.js: the watch loop never readsserve.ts, and loading it costs the whole runtime graph.compile.binPathpoints at them, so the refusal text belongs to the fixture and no CLI prose is pinned.--verboseinheritance is checked against the test process's own streams, not read off the spawn options.What it pins:
onRebuildLandedfires with the label.--verbose: the child's fd 1 and fd 2 are the test process's own, and the failure line repeats nothing.Ablation, on
4f4f529e9throughscripts/ablation-replace.mjs. The test imports./dev.jsfromsrc, so there is no dist leg.['ignore', 'ignore', 'pipe'], the pre-fix capture. The anchor count went 1 to 0, and the blob changed from4ada8ac0d61atob74790b2dffb.expected ' ✗ compile failed (46ms)' to contain 'FIXTURE-REFUSAL: …', the card's symptom exactly. Cases 3 and 4 stayed green.git diff HEADempty.Local runs. All at
4f4f529e9unless noted, throughscripts/pm/os-verify-lock.sh. The box is shared, so timings are shared-box seconds.pnpm --filter @objectstack/cli run typecheck: exit 0. That coverstsc --noEmitandcheck:test-typecheck.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2over the pin and the seven unit files that import or readdev.ts(child-env-source-loader.pin,artifact-child-env.pin,dev-mcp-connect-hint-origin,port-contract-single-source,console-route-ledger.conformance,strip-arg-separator,test/commands): exit 0, 8 files, 143 tests passed.serve.jswas stubbed. Import accounts for 12 s of that and the tests for 1.5 s.pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2: exit 0, at1aa612afc. That was the merge oforigin/main, anddev.tshas not changed since.pnpm exec eslint --no-inline-configon both changed.tsfiles: exit 0. The repo-wide lint run is CI'sLint & Repo Gates.Gate families.
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 64. Reconciled with--ran(every line carries its exit code): 64 accounted, 62 run with exit 0. Two are NOT MEASURED, each exiting 3 withPREREQUISITE NOT MET. Both need packages outside the@objectstack/cliclosure built:check:dual-build-cjs-loadslistsstudio,client-react,connector-slackand others with nodist/.check:i18n-coverageneedsexamples/app-showcase'sconnector-slackbuilt.CI's
Lint & Repo Gatesruns both. The four artifact-roster gates whose roster sits under a changed path all exited 0:check-changeset-fixed,check:authz-resolver,check:error-code-casingandcheck:filter-alias-parity. So didcheck:nul-bytes.Declared to CI, not run locally.
integrationtier. The diff touches no integration-tier file and no spawn entry (bin/,test/helpers/serve-process.ts).test/dev-no-watch.pin.test.tsimportsdev.tsand is integration-tier.test:repo.Acceptance notes
✗ compile failedwith whatever output it produced, and does not name the signal.r.error(a spawn error) is not printed either. Both were already true and are not reproduced here: noted, not filed.✗ compile failed, runpnpm buildto see why" is obsolete once this ships in an@objectstack/clirelease. That is the objectos repo's edit to make.Generated by Claude Code