Skip to content

fix(cli): the os dev watcher prints why a recompile was refused - #23867

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23820-dev-watch-compile-reason
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23820-dev-watch-compile-reason

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23820

Clause-②: no

What changed

os dev's watch-recompile loop (compileAndPing in packages/cli/src/commands/dev.ts) now captures the compile child's stdout as well as its stderr. On a non-zero exit it prints both under the ✗ compile failed line: the same lines pnpm build prints, with no --verbose needed. Nothing else changes:

  • A successful recompile prints exactly what it printed before. The compile's progress output stays hidden on success.
  • --verbose still passes both streams straight through, so a failure prints nothing twice.
  • A failed compile still writes no artifact and still triggers no restart.

Measured: why the refusal never appeared

Setup: a fresh os init my-app -t app --no-install scaffold (namespace my_app), made with the CLI built from this branch. Dependencies are 18.0.0-next.2 from npm. An object named task is wired into src/objects.

  • os compile exits 2. It writes 10 lines (517 bytes) to stdout and 0 bytes to stderr, so the refusal is on stdout. pnpm build prints the same 10 lines and exits 2.

  • os dev --fresh -p RANDOM boots, the server answers, and the object is saved. Before (base 1eac7ad751):

      ↻ recompiling (2 files)...
      ✗ compile failed (3782ms)
    
  • After (this branch, same scaffold, same save):

      ↻ recompiling (2 files)...
      ✗ compile failed (4012ms)
    ◆ Compile
    ────────────────────────────────────────
      → Loading configuration...
    
      ✗ defineStack namespace-prefix validation failed (1 issue):
    
      ✗ Object 'task' is missing the package namespace prefix. Rename it to 'my_app_task' (namespace = 'my_app').
    
    Every object.name must be `${manifest.namespace}_${shortName}`. This is the only supported writing style — the platform does not provide ns() helpers or factory wrappers.
    

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)

  • compile writes to stdout on purpose. It renders every text-face refusal through printError, which writes to stdout: 12 call sites in compile.ts, and the printer is used by 61 command files. packages/cli/src/utils/format.ts states this as the convention. printErrorToStderr exists 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".
  • Moving refusals to stderr (B) breaks more than it fixes. It goes against that convention, either for compile alone or for every command. It also changes the streams every other caller sees: os dev's boot compile, os start and os environments bind spawn compile with inherited streams. The e2e suites that spawn it would see it too.
  • The watcher is the only caller that threw stdout away, so the fix is a change at that one site (A).

What is printed

  • The whole output, not only the refusal block. The watcher prints the compile's stdout, then its stderr. It drops leading blank lines and trailing whitespace and filters nothing else.
    • On the card's case, 4 of the 10 lines are the compile's header and progress: the blank line, ◆ 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.
    • Picking "the refusal" out would mean dev.ts parsing compile'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.
  • stdout comes before stderr. They arrive through two separate pipes, so their interleaving cannot be recovered. stdout goes first because that is where the refusal is.
  • maxBuffer is 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

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:

  • Real code under test. The pin drives the real startWatchRecompile and compileAndPing. It fakes only chokidar, plus a stub for ./serve.js: the watch loop never reads serve.ts, and loading it costs the whole runtime graph.
  • Fixture scripts stand in for compile. binPath points at them, so the refusal text belongs to the fixture and no CLI prose is pinned.
  • Inheritance is observed. Each fixture records the identity (device and inode) of its fd 1 and fd 2. So --verbose inheritance is checked against the test process's own streams, not read off the spawn options.

What it pins:

  1. A refusal written to stdout only (exit 2) lands on the failure line, and the whole stdout appears in order. No restart is requested.
  2. With output on stdout and stderr (exit 1), both appear, stdout first.
  3. CONTROL: a successful recompile prints exactly two lines, as before. The fixture's progress output does not appear, and onRebuildLanded fires with the label.
  4. CONTROL, --verbose: the child's fd 1 and fd 2 are the test process's own, and the failure line repeats nothing.

Ablation, on 4f4f529e9 through scripts/ablation-replace.mjs. The test imports ./dev.js from src, so there is no dist leg.

  • Mutation. stdio reverts to ['ignore', 'ignore', 'pipe'], the pre-fix capture. The anchor count went 1 to 0, and the blob changed from 4ada8ac0d61a to b74790b2dffb.
  • Result. Cases 1 and 2 went red: expected ' ✗ compile failed (46ms)' to contain 'FIXTURE-REFUSAL: …', the card's symptom exactly. Cases 3 and 4 stayed green.
  • Restore. Blob equal to HEAD, and git diff HEAD empty.

Local runs. All at 4f4f529e9 unless noted, through scripts/pm/os-verify-lock.sh. The box is shared, so timings are shared-box seconds.

  • pnpm --filter @objectstack/cli run typecheck: exit 0. That covers tsc --noEmit and check:test-typecheck.
  • pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 over the pin and the seven unit files that import or read dev.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.
  • The pin alone: 4 of 4 passed. The file's wall time on this shared box went from 30.7 s to 13.8 s once serve.js was 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, at 1aa612afc. That was the merge of origin/main, and dev.ts has not changed since.
  • pnpm exec eslint --no-inline-config on both changed .ts files: exit 0. The repo-wide lint run is CI's Lint & Repo Gates.

Gate families. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 64. Reconciled with --ran (every line carries its exit code): 64 accounted, 62 run with exit 0. Two are NOT MEASURED, each exiting 3 with PREREQUISITE NOT MET. Both need packages outside the @objectstack/cli closure built:

  • check:dual-build-cjs-loads lists studio, client-react, connector-slack and others with no dist/.
  • check:i18n-coverage needs examples/app-showcase's connector-slack built.

CI's Lint & Repo Gates runs 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-casing and check:filter-alias-parity. So did check:nul-bytes.

Declared to CI, not run locally.

  • The integration tier. The diff touches no integration-tier file and no spawn entry (bin/, test/helpers/serve-process.ts). test/dev-no-watch.pin.test.ts imports dev.ts and is integration-tier.
  • test:repo.

Acceptance notes

  • Signals and spawn errors are still not explained. A compile killed by a signal (for example the OOM killer) still prints ✗ compile failed with 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.
  • The objectos quickstart workaround can be removed after release. The quickstart's line "If it prints only ✗ compile failed, run pnpm build to see why" is obsolete once this ships in an @objectstack/cli release. That is the objectos repo's edit to make.

Generated by Claude Code

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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@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 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
  • 1 anchor(s) matched too much of the corpus to be a work list: os dev (command, 33 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 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 f7e836da1ed344aaa6936b9de8b173d0f326b9ec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from bb778d30b352cef9853c33fa9951ed8a9dd4d46f — the merge of head 4f4f529e91dea86ca821f3aab00e7fe74f49ee81 into base f7e836da1ed344aaa6936b9de8b173d0f326b9ec, 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 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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #23820 · domain:cli seat 2 · os-tesla · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-16T10:14Z · reviewed head 4f4f529e91dea86ca821f3aab00e7fe74f49ee81, against GitHub and the branch, not against the report's prose.

  1. Shape. Draft, base main, first line Fixes #23820, the only closing keyword. Clause-②: no at line start in the body and in the changeset, matching claim 6212065983. Files: dev.ts (compileAndPing only, about :927-:966), a new pin, a changeset; inside the claim's surface. resolveDevDatabase and the database-directory mkdirSync (seat 1's PR fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851) are untouched. check-governed-merges --pr 23867: 0 of 3 paths governed; 321 changed lines.
  2. Premise, and the fix chosen. Measured on 1eac7ad751: the refused compile writes 10 stdout lines and 0 stderr bytes. The dev chose (A), the watcher captures stdout too, over (B), moving compile's refusals to stderr, with evidence I re-read on the branch. printError (packages/cli/src/utils/format.ts :375) writes with console.log, the text-face convention that file documents beside printErrorToStderr. compile.ts calls it 12 times. (B) would change every caller of a printer 61 command files share; (A) touches the one caller that threw stdout away. The PM's assumption 2 (print only the refusal block) was falsified on purpose and with reason: picking "the refusal" out would mean dev.ts parsing another command's glyphs. Read in the diff: stdio becomes ['ignore', 'pipe', 'pipe'] (--verbose still inherit); maxBuffer is 64 MiB, so a large successful compile is not killed by the 1 MiB default; on a non-zero exit the failure line is followed by stdout, then stderr, trimmed; success prints as before.
  3. Changeset, sentence by sentence. @objectstack/cli patch. "the same lines pnpm build prints on that tree, without --verbose": the whole output is relayed unfiltered. The Before line matches the dev's live run on a fresh os init -t app scaffold, and so does the After line (the namespace-prefix refusal). "A successful recompile prints exactly what it printed before … --verbose … nothing is printed twice … no restart": the two CONTROL cases of the pin and the unchanged else arm.
  4. Gates, pinned to the head. 4f4f529e9 check-runs read at review: 37; 19 success, 6 skipped, 12 in_progress. Landing waits for every check green or an expected skip. The dev's local subset: 62 of 64 derived gate commands exit 0 (--ran: 2 NOT-MEASURED, check:dual-build-cjs-loads and check:i18n-coverage, exit 3 for packages outside the CLI closure; CI runs both); cli typecheck; 8 unit files / 143 tests including the new pin (4/4); the ablation (stdio back to ignore stdout) reds exactly the two refusal cases; the live before/after at the public door.
  5. Accepted deviations. (1) One tsc --noEmit outside the verify lock, followed by locked runs. (2) The live run used the worktree's built CLI over a scaffold installed from npm 18.0.0-next.2, since the installed CLI cannot show the fix. (3) A merge of origin/main (a31ff2eb5) before the PR opened, with no conflict.
  6. Out of scope. (1) The objectos quickstart's workaround line ("run pnpm build to see why") becomes obsolete once a release carries this; the carrier is the repo:objectos seat ([PM seat] repo:objectos — ⏳ vacant · last os-tesla · session_01CXCwzqwMhkphKoBC11oPSz · R1 closed 2026-10-17 · landed 5 (objectos#331 #332 #333 #335 #336) · in flight 0 · queue 0 · 决策箱 1 #9831), and this seat leaves it a pointer at landing. (2) A compile killed by a signal or failing to spawn still prints no reason; pre-existing, not reproduced: Acceptance notes. Watch item: the new pin measured 13.8 s of wall on a contended shared box (import 12.05 s, tests 1.52 s). If the hourly battery puts it over 10 s, it joins this lane's test-budget card test budget (domain:cli): 119 test file(s) over 10 s of wall in the hourly full battery (Part of #23151) #23181 rather than going unremarked.

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 MERGED.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 10:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 10:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit 41e84c0 Oct 16, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23820-dev-watch-compile-reason branch October 16, 2026 10:57
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/m tests tooling

Projects

None yet

2 participants