Skip to content

fix(cli): os serve no longer hangs on a runtime state file directory that can never be made - #24088

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23858-r2-serve-state-dir
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23858-r2-serve-state-dir

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23858

Clause-②: no

The card's remainder after PR #23979 (which landed as Part of): os serve's runtime state file directory, plus the two in-package copies of the bounded walk. The state-file site now goes through the CLI's module-internal makeDirectorySync. The two copies are kept, not folded, by the claim's own rule: a fold onto makeDirectorySync cannot keep their answers and error text byte for byte (measured below).

What was wrong

runtimeBoundPortChannels().writeRuntimeState in packages/cli/src/commands/serve.ts made OS_HOME with fs.mkdirSync(path.dirname(runtimeFile), { recursive: true }). Node's recursive mkdir (v22.22.0) never returns when a level answers ENOENT while its parent exists, as /proc answers for a name it does not have. publishBoundPort writes the state file after the bind and before the banner, so the server held its port, printed no Press Ctrl+C to stop, sent no listening message to an os dev parent, and answered nothing.

What changed

  • serve.ts: that one call is now makeDirectorySync(path.dirname(runtimeFile)), imported from ../utils/make-directory.js. Nothing else in the file moves. The refusal lands in the write's existing best-effort catch, so the boot goes on without the file, as it already did for every other refusal (see Acceptance notes: nothing tells the operator).
  • packages/cli/test/uncreatable-directory-doors.integration.test.ts gains two legs (below). No new spawning file, so the check:cli-test-child-env population is unchanged.
  • .changeset/23858-serve-state-file-directory.md: @objectstack/cli patch, Clause-②: no.
  • .changeset/23858-uncreatable-directory-refusal.md (PR fix(cli,runtime): refuse an uncreatable state, output or cache directory at once instead of looping in recursive mkdir #23979's pending note) is NOT in this diff: it was restored byte-for-byte from the merge base by seat ruling B (6227399342). This PR's changeset says that it covers the runtime state file that note lists as "Not covered yet".

The two folds: kept, measured

The claim says each fold keeps its callers, its answers and its error text byte for byte, or the copy is kept. Measured function by function on this branch: each copy as it stands, against the fold as it would be written (each copy's own wrapper and message, verbatim, around its package's makeDirectorySync). dev = prepareDevDatabaseDirectory (dev.ts); runtime = makeSqliteDatabaseDirectory (standalone-stack.ts), reached through createStandaloneStack because it is module-private.

input (the database file's directory) dev runtime
creatable, three levels deep same same
already exists same same
/proc/MISSING/a same: ENOENT, same text same: ENOENT, same text
through a regular file same: ENOTDIR, same text same: ENOTDIR, same text
a regular file holds the directory's name same: EEXIST, same text same: EEXIST, same text
a dangling symlink is the directory differs: copy returns; fold refuses EEXIST differs: copy returns; fold refuses EEXIST
a dangling symlink mid-path differs: copy ENOENT … mkdir 'ROOT/dl2/sub'; fold EEXIST … mkdir 'ROOT/dl2' differs, the same way

Two causes, both in the walk itself, so neither wrapper can absorb them:

  1. EEXIST on a created level. The copies accept any EEXIST; makeDirectorySync accepts it only when a directory stands there (PR fix(cli,runtime): refuse an uncreatable state, output or cache directory at once instead of looping in recursive mkdir #23979's acceptance notes named this). A dangling symlink answers EEXIST to mkdir and ENOENT to stat.
  2. Async to sync. Both copies await each stat and mkdir. Measured on prepareDevDatabaseDirectory, event-loop turns that ran while the walk was pending: 46 for a three-level create, 100 for /proc, against 0 for the fold.

So both copies stay, byte for byte. Neither file is in this diff.

What the copies' answer costs at the door (base b6805ac0f, built CLI; unchanged here):

  • OS_HOME=SCRATCH/dl-home os dev, where SCRATCH/dl-home/data is a dangling symlink, exits 1 after 11.2 s with ✗ Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'. …. Nothing names the file, the directory or the symlink.
  • OS_DATABASE_URL=file:SCRATCH/dl-db/link/ctl.db os serve, where link is a dangling symlink, exits 1 after 5.3 s with ✗ Plugin package-service failed to start - rollback complete: The database refused to run a raw statement. ….
  • The fold would turn both into an immediate refusal that names the file, the directory and EEXIST. That is a change of answer, which the claim's rule keeps out of this PR. It is in the report for the seat.

Measured at the public door

Built CLI, Node v22.22.0, a scratch project outside the repo with export default {}; (and a defineStack project for os dev). Each run is in its own process group, killed whole at the bound. strace -f -c -e trace=mkdir,mkdirat counts the calls.

command base b6805ac0f this branch
OS_HOME=/proc/os-23858-r2 OS_DATABASE_URL=:memory: os serve hung after the bind: 794,594 failing mkdir in 20 s (all mkdir("/proc/os-23858-r2") / mkdir("/proc")), no banner, killed at the bound Press Ctrl+C to stop at 4.7 s; 1 failing mkdir in the whole run
OS_HOME=/proc/os-23858-r2 os dev --database :memory: --no-watch (the serve child reaches the same write) hung: 1,392,733 failing mkdir in 40 s, no banner banner and MCP connect block at 13.2 s; 1 failing mkdir
OS_HOME=/proc/os-23858-r2 os serve (SQLite default inside OS_HOME) exit 1 in 3.5 s: ✗ [StandaloneStack] SQLite database file '/proc/os-23858-r2/data/objectstack.db' cannot be created: … ENOENT … the same line, exit 1 in 3.8 s
OS_HOME=/proc/os-23858-r2 os start (also with OS_DATABASE_URL=:memory:) exit 1 in 3.5 s: ✗ Cannot create home directory at /proc/os-23858-r2: ENOENT … (PR #23979's door, before any serve child) the same, exit 1 in 4.2 s
fold door, OS_HOME=/proc/os-23858-r2 os dev (unified default) exit 1 in 3.6 s: ✗ The default database '/proc/os-23858-r2/data/objectstack.db' cannot be created: its directory '/proc/os-23858-r2/data' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-23858-r2' the same line, exit 1 in 3.5 s
fold door, OS_DATABASE_URL=file:/proc/os-23858-r2-db/a/ctl.db os serve exit 1 in 3.3 s: ✗ [StandaloneStack] SQLite database file '/proc/os-23858-r2-db/a/ctl.db' cannot be created: its directory '/proc/os-23858-r2-db/a' could not be made: ENOENT: …, mkdir '/proc/os-23858-r2-db' the same line, exit 1 in 3.5 s

os start never reaches the state-file write under an uncreatable home: it makes HOME_DIR/data first and refuses there. The door that reaches it is os serve (or os dev's serve child) with the database outside OS_HOME.

The remaining recursive mkdir sites in the two packages

git grep -n "recursive: true" over packages/cli/src and packages/runtime/src at base b6805ac0f, test files excluded: 18 lines. 3 are rmSync, 2 are readdirSync in doc comments, and 6 are doc comments naming the old call. That leaves 7 mkdir code sites; a multi-line search (mkdir(Sync)?\( without recursive on the line) finds only the walks' own non-recursive calls.

site path can operator input make it uncreatable?
cli/src/commands/serve.ts:1097 OS_HOME (the state file) yes: this PR
cli/src/commands/create.ts:740 template-constant dirs under targetDir, just made by makeDirectorySync no, by construction
cli/src/commands/dev.ts:572, :577 --fresh home's data and uploads, one level under mkdtempSync no, by construction
cli/src/commands/init.ts:1122 template-constant dirs under targetDir, just written into no, by construction
cli/src/utils/build-runtime.ts:198 outputDir; its only caller passes compile's artifactDir, made or refused by makeDirectorySync no, by construction
cli/src/utils/dev-organization-ownership-upgrade.ts:128 two constant levels under the SQLite file's own directory, which exists no, by construction

packages/runtime/src has no recursive mkdir left.

Pins

packages/cli/test/uncreatable-directory-doors.integration.test.ts, two new legs among its concurrent children (6 in all), each its own process group, killed whole at 60 s:

  1. os serve --port 0 under OS_HOME=/proc/MISSING with OS_DATABASE_URL=:memory: prints Press Ctrl+C to stop inside the bound. The group is killed on that text.
  2. CONTROL: the same under a creatable OS_HOME three levels deep prints the banner, and by then the home holds exactly one runtime.env_local.PROJECT.json whose pid is the child's own.

File wall 6.38 s (6 of 6), under the 10 s line. --port 0 keeps both off any fixed port.

Negative control (ablation)

On committed head b3fa3f11d, through scripts/ablation-replace.mjs in wrap mode under the verify lock:

  • Mutation: the anchor makeDirectorySync(path.dirname(runtimeFile)); (x1 → x0) became fs.mkdirSync(path.dirname(runtimeFile), { recursive: true }); (x0 → x1), blob 87014c3470c7 → f238ec2b0a69.
  • Into dist/: pnpm --filter @objectstack/cli build exit 0; node scripts/ablation-dist-preflight.mjs @objectstack/cli 'dirname(runtimeFile), { recursive: true }' exit 0, marker present in 1 built file. The door pin runs dist/.
  • Result: 1 failed, 5 passed in 60.44 s: 'serve-proc' had not settled after 60000 ms and was killed — a directory prep is looping again. Both serve and start CONTROLs stayed green.
  • Restore: blob after restore == HEAD (87014c3470c7), git diff HEAD empty. CLI rebuilt exit 0; preflight --absent: marker absent from all 652 built files, working tree clean. The pin re-ran 6 of 6 green (6.52 s).

Tests and gates

Exit codes captured before any pipe. After merging origin/main (8133cc780) into the branch, head fd250408f:

  • pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2: exit 0 (61 tasks, 27 cached).
  • pnpm --filter @objectstack/cli run typecheck: exit 0. pnpm --filter @objectstack/runtime run typecheck: exit 0. The pin file is in tsconfig.test.json's program (--listFiles) with 0 errors.
  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit (the whole unit tier): exit 0, 308 files, 4,582 tests. It includes src/commands/dev-database-directory.test.ts, src/utils/make-directory.test.ts, serve-bound-port-publication, serve-no-transport-announcements, serve-bound-port-publish-order and serve-publishes-bound-port.e2e.
  • --project integration for test/uncreatable-directory-doors.integration.test.ts and test/serve-runtime-state-project-key.test.ts (the other state-file pin): exit 0, 2 files, 18 tests.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --project local src/standalone-stack-uncreatable-db-directory.test.ts src/make-directory.test.ts (the runtime copy's own pin, unchanged, and the runtime walk's): exit 0, 2 files, 12 tests.
  • pnpm exec eslint --no-inline-config --max-warnings=0 over the two changed .ts files: exit 0. The repo-wide run is CI's Lint & Repo Gates.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 67 commands, all run one by one on fd250408f: 66 exit 0, and node scripts/check-empty-changeset.mjs --base origin/main exit 1 there (the pending-note edit, since restored). On c31899a8a all 67 are accounted for with exit 0, and CI's Check Changeset is success.
    • Three first ran as exit 3 (NOT MEASURED) and were re-run to a reading: check:type-check-debt, whose closure build had not got the verify lock, re-run under the lock: exit 0, --re-measure: OK — 1 ledger entr(ies) re-measured … none above its recorded number. That build also built the packages check:dual-build-cjs-loads (exit 0, 110 require entry points across 68 packages load) and check:i18n-coverage (exit 0, 14 configs, none new) had found missing.
    • --ran reconciliation: 67 derived famil(ies) accounted for — 67 run, 0 NOT-MEASURED, exit 0.
    • node scripts/check-nul-bytes.mjs: exit 0.
  • Left to CI: the rest of packages/cli's integration tier (no other integration file and no spawn entry is touched), and the runtime package's suites (the runtime is not in this diff).

Acceptance notes

  • Seat ruling B (6227399342). The pending .changeset/23858-uncreatable-directory-refusal.md line "Not covered yet: the runtime state file os serve writes into OS_HOME once it is listening." is superseded by this PR's changeset. It is left in place because the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712 guard gates any edit of another PR's pending changeset on a person's confirmation. carrier: the release owner, at changeset version time: drop that line from the compiled entry.
  • A refused state-file write still tells nobody. The catch { /* non-fatal — supervision file is best-effort */ } around the write prints nothing. Measured on this branch: under OS_HOME=/proc/os-23858-r2 and under OS_HOME=SCRATCH/afile/sub, where afile is a regular file (ENOTDIR), os serve prints its banner at about 4.6 s, and its output names neither the state file nor the error. A supervisor told "ready" then gets ENOENT on the file. The ENOTDIR case answered that way before this PR too. Before this PR the /proc case hung instead. The catch's posture is not this card's; it is in the report.
  • dev.ts and standalone-stack.ts are not in this diff (see The two folds).
  • The walk now has five definitions in three packages, two semantics: the strict one (makeDirectorySync, both packages) and the lenient one (driver-sql's makeDatabaseDirectory and the two copies above).

Generated by Claude Code

@github-actions

github-actions Bot commented Oct 17, 2026 •

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 serve (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 — 29 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 be076dad28a0d35cefc7f76cd06950620cb8b81b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3d6d5a563035bacebd80e7d293779921c2d27181 — the merge of head c31899a8a70fc10c6564489b680dbd22a6ba1a0e into base be076dad28a0d35cefc7f76cd06950620cb8b81b, 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 3d6d5a563035bacebd80e7d293779921c2d27181 && git checkout 3d6d5a563035bacebd80e7d293779921c2d27181
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin be076dad28a0d35cefc7f76cd06950620cb8b81b c31899a8a70fc10c6564489b680dbd22a6ba1a0e && git checkout -B drift-repro be076dad28a0d35cefc7f76cd06950620cb8b81b && git merge --no-ff c31899a8a70fc10c6564489b680dbd22a6ba1a0e

node scripts/docs-audit/affected-docs.mjs --json be076dad28a0d35cefc7f76cd06950620cb8b81b

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

…vers the state file that note left uncovered

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #23858 (round 2, the remainder after PR #23979) · domain:cli seat 2 · os-tesla · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-17T00:59Z · reviewed head c31899a8a70fc10c6564489b680dbd22a6ba1a0e, against GitHub and the branch, not against the report's prose. Holder of claim 6226232050; reports on #23858, rulings 6227399342.

  1. Shape.
    • Draft, base main, assignee os-tesla. Line 1 is Fixes #23858; Clause-②: no is in the body and the changeset.
    • 3 files, +103 / −14.
    • check-governed-merges --pr 24088: NOT governed; 117 changed lines.
    • git merge-tree is clean against origin/main.
    • .changeset/23858-uncreatable-directory-refusal.md is byte-identical to the merge base (ruling B).
  2. The change, read by this seat against the claim's surface.
    • serve.ts writeRuntimeState calls the CLI's module-internal makeDirectorySync for the state file's directory, in place of fs.mkdirSync(…, { recursive: true }). That is one call site and one import, with no new export.
    • The refusal lands in the write's existing best-effort catch, so the boot goes on without the file, as before for every other refusal.
    • The two folds are not made, by the claim's own keep-the-copy rule. The dev measured that a fold changes the answer for a dangling symlink, and the copies' async yield.
  3. Review of record: this record. A hang becoming a prompt banner has no contract face: no accepted input, flag or export changes.
  4. Changeset: .changeset/23858-serve-state-file-directory.md, @objectstack/cli patch. It says it covers the state file the round-1 entry lists as "Not covered yet".
  5. Gates, pinned to the head. Check-runs on c31899a8a, read at this stamp, latest run per name: 40 names, 32 success, 8 skipped, none failed, none running.
    • The successes include Build Core, Test Core 1–6/6, TypeScript Type Check, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard.
    • The skips are roster names.
    • The dev's local reading: 67 derived, 67 run, 0 NOT-MEASURED. CLI unit tests: 4582 passed. The door pin: 6 / 6.
    • The ablation (the recursive mkdir put back) went red at the bound, and the restore was proven.
    • At the public door, base against head:
      • OS_HOME=/proc/os-23858-r2 os serve made 794,594 failing mkdir calls in 20 s and printed no banner at base, and prints the banner at 4.7 s at head;
      • os dev behaved the same way (1,392,733 calls in 40 s at base, 13.2 s at head).
  6. Accepted deviations:
    • the pin is two legs in the existing door file;
    • one main merge;
    • the runtime's full suites left to CI;
    • ruling B's restore of the round-1 note.

Filed from this round:

Carried to the release owner: the round-1 note's "Not covered yet" line, at changeset version time (ruling B).

Landing: pr_ready + automerge_enable through the relay, after this record lands. The card closes on the merge (Fixes).


Generated by Claude Code

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