Skip to content

fix(cli,runtime): refuse an uncreatable state, output or cache directory at once instead of looping in recursive mkdir - #23979

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23858-recursive-mkdir-family
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23858-recursive-mkdir-family

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #23858

Clause-②: no

Part of, not a full close: packages/cli/src/commands/serve.ts:978 (the runtime state file's directory) is in scope by the classification below and measured hanging, but it sits inside seat 1's claim on #23743 (publishBoundPort and the state-file write it drives), so this PR does not edit it. The dispatching seat carries :978 to after #23743 lands; #23858 remains open for that one site.

What was wrong

Node's recursive mkdir (v22.22.0, sync and async alike) never returns when a level answers ENOENT while its parent exists. /proc and /sys answer that way for a name they do not have. It loops mkdir("/proc/x") = ENOENT, mkdir("/proc") = EEXIST, retry. The synchronous form holds the event loop, so no timer or plugin timeout can fire. Every recursive mkdir in packages/cli and packages/runtime that an operator- or author-supplied path reaches had this shape.

What changed

  • One module-internal walk per package. makeDirectorySync(dir) lives in packages/cli/src/utils/make-directory.ts and packages/runtime/src/make-directory.ts. It is the bounded walk driver-sql (makeDatabaseDirectory) and PR fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851 (makeSqliteDatabaseDirectory, prepareDevDatabaseDirectory) already run, made synchronous so it replaces mkdirSync where it stood:
    • find the nearest existing level, then create the missing levels top-down with a non-recursive mkdir;
    • accept EEXIST on a created level only when a directory now stands there (a concurrent creator). The recursive form accepts exactly that. The fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851 copies accept any EEXIST, so a dangling symlink there read as made;
    • throw the operating system's own error at once for anything else. Its code is intact, and its message names the level that cannot be made.
    • It returns the first directory it created, or undefined, the same as mkdirSync(…, { recursive: true }). os generate's rollback (generate.ts:1805) reads that value.
  • Not on any package entry. @objectstack/runtime's exports names . alone and src/index.ts does not export the module; the built dist/index.d.ts has 0 hits for makeDirectorySync. @objectstack/cli's exports names ., ./console and ./hook-body, and none of them reaches the module. No packages/spec edit, no new error code, and no ERROR_CODE_LEDGER row. The refusal is the operating system's code, as 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's was.
  • 16 sites now call it (table below). Each site's existing error handling prints the refusal: start's Cannot create home directory at …, the artifact resolver's OS_ARTIFACT_UNREACHABLE, create's Failed to create project:, oclif's error block for init, and so on.
  • .changeset/23858-uncreatable-directory-refusal.md: @objectstack/cli patch, @objectstack/runtime patch, Clause-②: no.
  • Two existing CLI tests pinned what this PR replaces. Each now pins the new step:
    • test/remote-api-utils.test.ts asserted the credentials directory went through a mocked fs.promises.mkdir(…, { recursive: true }). It now mocks the bounded walk and asserts that, so it writes nothing real.
    • test/validate-build-gate-parity.test.ts requires every compile.ts callee to be classified. makeDirectorySync joins the "local helpers, no diagnostic of its own" bucket.
  • Outside the claim's declared file surface: scripts/check-cli-test-child-env.mjs. Its self-test pins the set of test files that spawn the built bin/run.js ("so a file joining or leaving the population has to be seen by a PR"). The door pin joins that set, nine → ten, with a reason line in the same form as the five earlier joiners. The tsx entry would have kept the pin out of the set, but it costs about 10 s per child against about 3 s, measured, which would put the file over the 10 s line.

The family, classified

Instrument, on origin/main 0744f94366 (this branch's base): git grep -nE "mkdir(Sync)?\(.*recursive: *true" over packages/cli/src and packages/runtime/src, test files excluded. That gives 24 lines: 21 synchronous lines (two of them are doc comments, dev.ts:102 and standalone-stack.ts:677) and 3 async mkdir calls. Calls wrapped across lines were searched with a multiline mkdir(Sync)?\([^;]*\n[^;]*recursive over both trees: 0 code hits (only the two doc comments). That leaves 22 code sites.

In scope = an operator- or author-supplied path reaches the site. Out = the path is creatable by construction.

# site (on 0744f94366) path comes from class disposition
1 cli/src/commands/start.ts:189 HOME_DIR/data --home / OS_HOME / cwd in, measured bounded
2 cli/src/commands/start.ts:658 HOME_DIR (auth secret) the same home; reached only after site 1 made HOME_DIR/data in (same operator path, shielded by site 1) bounded
3 cli/src/commands/serve.ts:978 dirname(runtimeFile) = OS_HOME OS_HOME in, measured held off (#23743)
4 cli/src/commands/compile.ts:992 artifactDir --output in bounded
5 cli/src/commands/create.ts:730 targetDir --dir, or the name under cwd in, measured bounded
6 cli/src/commands/create.ts:738 template dirs template-constant relative paths under targetDir, which site 5 just made out: by construction unchanged
7 cli/src/commands/dev.ts:571 freshHome/data one constant level under mkdtempSync(os.tmpdir()/…), just made. An uncreatable TMPDIR is refused by mkdtempSync itself, which is not recursive out: by construction unchanged
8 cli/src/commands/dev.ts:576 freshHome/uploads same out: by construction unchanged
9 cli/src/commands/generate.ts:1805 fullDir --dir (joined to cwd; .. climbs out) in bounded; return value kept
10 cli/src/commands/generate.ts:2227 types outDir --output in bounded
11 cli/src/commands/generate.ts:2374 client outDir --output in bounded
12 cli/src/commands/generate.ts:4006 migration outDir --output in bounded
13 cli/src/commands/i18n/extract.ts:799 resolvedOutDir --out in bounded
14 cli/src/commands/init.ts:1121 template src dirs template-constant paths under targetDir, which had just taken the objectstack.config.ts write out: by construction unchanged
15 cli/src/commands/init.ts:1265 startCwd/NAME the cwd and the name argument in, measured bounded
16 cli/src/utils/build-runtime.ts:198 outputDir only caller compile.ts:1070 passes artifactDir, which site 4 made or refused out: exists by construction unchanged
17 cli/src/commands/plugin/build.ts:221 (async) --out in bounded
18 cli/src/utils/auth-config.ts:74 (async) HOME/.objectstack HOME (os login) in bounded
19 cli/src/utils/cloud-config.ts:94 (async) HOME/.objectstack HOME (os cloud login) in bounded
20 runtime/src/default-host.ts:136 OS_HOME/dist (empty-boot stub) OS_HOME in, measured bounded
21 runtime/src/package-state-store.ts:153 OS_HOME/package-state OS_HOME, through the packages routes (install, PATCH …/enable, PATCH …/disable) in (read: same call shape; at os serve today site 3 hangs first) bounded
22 runtime/src/artifact-reference.ts:395 OS_HOME/artifacts OS_HOME with an http(s): OS_ARTIFACT_URL in, measured bounded
23 cli/src/utils/dev-organization-ownership-upgrade.ts:128 backups/organization-ownership-STAMP two constant levels under the directory of the SQLite file os dev is upgrading, which exists and was just opened. Landed on main after this branch's base (PR #23906) and arrived with the merge of origin/main out: by construction unchanged

Totals: 17 in scope, 16 bounded here, 1 held off (site 3); 6 out (sites 6, 7, 8, 14, 16 and 23). The same instrument re-run on the merged tree finds exactly these 23 code sites and no call wrapped across lines. standalone-stack.ts and dev.ts's database prep were already bounded by PR #23851 and are not touched.

Measured at the public door

Built CLI on this container, Node v22.22.0. Each run is in its own process group, killed whole at the bound by a harness. strace -f -e trace=mkdir,mkdirat counts the calls.

command origin/main 0744f94366 this branch
OS_HOME=/proc/os-23858-home os start hung after the header; 168,465 failing mkdir in 20 s, killed at the bound exit 1 in 4.2 s (under strace), 1 mkdir: ✗ Cannot create home directory at /proc/os-23858-home: ENOENT: no such file or directory, mkdir '/proc/os-23858-home'
CONTROL: OS_HOME=SCRATCH/a/b/c os start ✓ Server is ready; home made (data, dist, state file) ✓ Server is ready; the same five entries in the home
OS_HOME=/proc/os-23858-dh OS_BOOT_EMPTY=1 os serve 1,107,199 failing mkdir("/proc/os-23858-dh/dist") … in 25 s exit 1 in 3.9 s: ✗ ENOENT: no such file or directory, mkdir '/proc/os-23858-dh'
OS_HOME=/proc/os-23858-ar OS_ARTIFACT_URL=http://127.0.0.1:PORT/objectstack.json os serve 1,103,490 in 25 s exit 1 in 4.7 s: ✗ Cannot boot from OS_ARTIFACT_URL. … could not write it to /proc/os-23858-ar/artifacts/staged-….json: ENOENT: …, mkdir '/proc/os-23858-ar'
os create plugin foo --dir /proc/os-23858-cr/foo hung after Location:; 889,246 in 20 s exit 1 in 4.1 s: ENOENT: no such file or directory, mkdir '/proc/os-23858-cr'
CONTROL: os create plugin foo --dir SCRATCH/create-ctl/a/b/foo not run exit 0; project written
os init os-23858-init, cwd /proc hung after the header; 781,172 in 20 s exit 1 in 4.3 s: Error: ENOENT: …, mkdir '/proc/os-23858-init', Code: ENOENT
OS_HOME=/proc/os-23858-sv OS_ARTIFACT_PATH=… OS_DATABASE_URL=file:… os serve (site 3, held off) hung after boot, before Server is ready: 2,019,800 failing mkdir("/proc/os-23858-sv") in 45 s unchanged: held off

Pins

Each /proc case runs in a child bounded with SIGKILL, so a regression fails at the bound instead of hanging the suite. The /proc cases run where /proc/self exists (Linux, which CI is).

  • packages/cli/test/uncreatable-directory-doors.integration.test.ts (integration tier: it spawns the built bin/run.js). Four concurrent children, each in its own process group, killed whole at 60 s:
    1. os start under OS_HOME=/proc/MISSING: exit 1, and the output names the path and ENOENT;
    2. CONTROL: os start under a creatable OS_HOME three levels deep makes HOME_DIR/data. --port 70000 is start's own invalid-port refusal, after the home step and before any serve child, so no leg boots a server;
    3. os create plugin --dir /proc/MISSING/foo: exit 1, naming the path and ENOENT;
    4. CONTROL: os create --dir three levels deep exits 0 and writes the project.
    • File wall: 4.12 s.
  • packages/cli/src/utils/make-directory.test.ts (unit tier):
    • one bounded node --import tsx child (45 s) runs the helper on /proc/MISSING/… plus writeAuthConfig and writeCloudConfig under HOME=/proc/MISSING. Each is ENOENT, naming the level;
    • in-process: ENOTDIR through a file, EEXIST for a file holding the name, a dangling symlink refused;
    • CONTROLS: a path three levels deep is made and the first created level is returned; an existing directory returns undefined.
    • File wall: 0.45 s.
  • packages/runtime/src/make-directory.test.ts (local project):
    • one bounded node --import tsx child (60 s) runs every OS_HOME subject: the helper; createDefaultHostConfig({ requireArtifact: false }), setPackageDisabled, and resolveArtifactReference with an injected fetchImpl → ENOENT, with the resolver's OS_ARTIFACT_UNREACHABLE carrying ENOENT and the path;
    • CONTROLS: under creatable homes three levels deep the empty-boot stub is written and the stack built, the state file is written and read back, and the artifact is cached.
    • The worker imports only the helper. The subjects' graph costs about 10 s of vitest transform, and the child's tsx pays it from its cache.
    • File wall: 2.83 s.

Negative control (ablation)

The ablation ran on the committed implementation, head d188c036d. The three pinned files and both helpers are byte-identical on this PR's head. Both helpers were mutated at once through scripts/ablation-replace.mjs, nested, inside one run under the verify lock:

  • Mutation. In each helper, the anchor const missing: string[] = []; (x1 → x0) became return mkdirSync(dir, { recursive: true }) ?? undefined; followed by const missing: Array<string> = []; (x0 → x1). That return is the recursive call the walk replaced.
    • CLI blob 7fc615b9673a → abd314b12e8b.
    • Runtime blob 496e6dab39ee → 5f636bc5557a.
  • Into dist/. pnpm --filter @objectstack/cli build: exit 0. node scripts/ablation-dist-preflight.mjs @objectstack/cli 'recursive: true }) ?? undefined': exit 0, marker present in packages/cli/dist/utils/make-directory.js. The door pin runs dist/; the other two run source through tsx.
  • Result: every pin reached its bound.
    • Door pin: 2 failed, 2 passed in 65 s. 'start-proc' had not settled after 60000 ms and was killed, and the same for 'create-proc'. Both CONTROLs stayed green.
    • CLI unit pin: the /proc cases had not settled after 45047 ms (child killed by SIGKILL), file FAIL in 49 s.
    • Runtime pin: the OS_HOME directory preps had not settled after 60087 ms (child killed by SIGKILL), file FAIL in 62 s.
  • Restore. The tool proved each blob equal to HEAD with git diff HEAD empty. After a CLI rebuild, ablation-dist-preflight.mjs … --absent found the marker absent from all 648 built files and the whole tree clean. The three files re-ran green: 4/4, 6/6, 8/8.
  • A first attempt was a no-op, refused by the tool before anything ran. Its replacement kept the anchor text, so the anchor count moved 1 → 1. The tool restored to the HEAD blob at once.

Tests and gates

Every exit code below was captured before any pipe.

  • After the merge of origin/main, on d188c036d:
    • pnpm exec turbo run build --filter='@objectstack/cli...' --concurrency=2: exit 0 (61 tasks, 11 cached).
    • pnpm --filter @objectstack/runtime run typecheck: exit 0. pnpm --filter @objectstack/cli run typecheck: exit 0. check:test-typecheck is OK for both, with no new test-layer debt.
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 --project local (the whole local project): exit 0. 403 files and 6,130 tests passed, 19 skipped.
  • pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 --project unit (the whole unit tier): exit 1. 302 of 304 files passed; 3 tests failed in 2 files, and both pinned what this PR replaces (see What changed):
    • test/remote-api-utils.test.ts asserted a mocked fs.promises.mkdir(…, { recursive: true });
    • test/validate-build-gate-parity.test.ts found the unclassified makeDirectorySync.
    • Both are updated in a2ef81b41. Re-run there, the two files plus src/utils/make-directory.test.ts give 3 files, 49 tests passed, exit 0; CLI typecheck exit 0.
  • The new pins, final runs: CLI unit 6/6; door pin 4/4 (--project integration, file wall 6.37 s); runtime 8/8 (file wall 3.82 s).
  • pnpm exec eslint --no-inline-config --max-warnings=0 over the 20 changed .ts / .mjs files: exit 0. The repo-wide run is CI's Lint & Repo Gates.
  • node scripts/check-cli-test-child-env.mjs --self-test: exit 0, 152 cases. The audit: exit 0, all 10 built-CLI spawns keep their child out of development/test.
  • Gate families on the final head a2ef81b41:
    • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 87 commands. All 87 ran: 87 exit 0, 0 exit 3, 0 red.
    • --ran reconciliation: 87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED, exit 0.
    • check:dual-build-cjs-loads and check:i18n-coverage were measured too: the closure build made their prerequisites present.
  • Left to CI:
    • packages/cli's integration tier, apart from the new door pin. No other integration-tier file and no spawn entry is touched.
    • The runtime's test:repo project.
    • The repo-wide lint.

Acceptance notes

  • The walk now exists as five copies in three packages:
    • driver-sql's makeDatabaseDirectory (async);
    • the runtime's makeSqliteDatabaseDirectory (async, standalone-stack.ts) and this PR's makeDirectorySync;
    • the CLI's inline walk in prepareDevDatabaseDirectory (async, dev.ts) and this PR's makeDirectorySync.
    • Inside each package they could fold into one module. That would edit standalone-stack.ts and dev.ts, both fenced off this card, so it is left to a later card in this lane.
    • Folding across packages needs a new export, which is the open question in the report.
  • The EEXIST strictness is new, and this PR does not apply it to the existing copies. The fix(runtime,cli): refuse an uncreatable SQLite database directory at once instead of hanging in recursive mkdir #23851 copies treat any EEXIST on a created level as made, including a dangling symlink, which the recursive form refuses with ENOENT. This PR's copies accept it only when a directory stands there. That case is never a hang, so the older copies are not in this card's class. It is noted here, not filed.
  • Error wording on inputs that already failed. On inputs the recursive form already refused, the code can now be more exact: EROFS where Node answered ENOENT, EEXIST for a dangling symlink. A path through a file can now name stat instead of mkdir. No input that succeeded before is refused (Clause-②: no).
  • os start latency. The refusal arrives after the CLI's own start-up, about 3 to 4 s wall on this box. The walk itself takes well under a millisecond.
  • Async sites now use the synchronous walk (plugin/build.ts, auth-config.ts, cloud-config.ts). It makes a handful of syscalls on a one-shot CLI path, and one copy per package is what the claim asked for.

Generated by Claude Code

…looping in recursive mkdir

Every recursive mkdir in packages/cli and packages/runtime that an operator- or
author-supplied path reaches now goes through a bounded, module-internal walk
(makeDirectorySync, one per package, exported from no package entry): find the
nearest existing level, create the missing ones top-down with a non-recursive
mkdir, and throw the operating system's own error at once for the first level
it refuses. Node's recursive mkdir never returns on a level that answers ENOENT
while its parent exists (a name under /proc), and the sync form held the event
loop: OS_HOME=/proc/... os start hung after its header.

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…d in bounded children; changeset

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…ls on every platform

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
… the built-entrypoint census

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…cursive-mkdir-family

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…dger; the credentials test mocks the bounded walk

Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/runtime, touching 22 documentable anchor(s).

35 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 0b6956490a1697bf6d5f3cda468d2ee3dd37d786.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 45 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 0b6956490a1697bf6d5f3cda468d2ee3dd37d786 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0b6956490a1697bf6d5f3cda468d2ee3dd37d786

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0b6956490a1697bf6d5f3cda468d2ee3dd37d786 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · #23858 (Part of) · domain:cli seat 2 · os-tesla · session session_018t6a5rvqPRvEDMCVVW9kYT · 2026-10-16T15:57Z · reviewed head a2ef81b41b, against GitHub and the branch, not against the report's prose. Holder of claim 6215742630; report 6218946925 on #23858.

  1. Shape. Draft, base main, assignee os-tesla. Line 1 is Part of #23858, because serve.ts :978 is held off for seat 1's cli/serve --no-server, the remainder of #23623: the objectstack:listening IPC message and the runtime state file still announce http://localhost:PORT with nothing listening, and a config-mounted server's banner names an unmounted MCP route #23743, as the claim ordered. The body has no closing keyword. 21 files, +865 / −32. check-governed-merges --pr 23979: NOT governed (0 of 21 paths). git merge-tree is clean against origin/main.
  2. The walk, read in the diff. makeDirectorySync is one module-internal function per package (packages/cli/src/utils/make-directory.ts, packages/runtime/src/make-directory.ts). The two bodies are the same logic in each package's indentation. On neither package's entry: the runtime's src/index.ts names neither.
    • It finds the nearest existing level by statSync and refuses any error other than ENOENT at once.
    • It then makes the missing levels top down with a non-recursive mkdirSync. An ENOENT from /proc/<missing> therefore throws at once instead of looping.
    • It accepts EEXIST only where a directory stands, so a file in the way still refuses (EEXIST / ENOTDIR).
    • It returns the first created level, as the recursive form does.
  3. The sites, read in the diff.
    • start.ts :189 / :658, default-host.ts, package-state-store.ts and artifact-reference.ts, plus compile, create, generate, i18n extract, init, plugin build, auth-config and cloud-config, swap mkdirSync(…, { recursive: true }) (or the async mkdir) for the walk.
    • The PR body classifies all 23 sites. 17 are in scope (16 bounded here, serve.ts :978 held off) and 6 are out by construction, each with its reason.
  4. Measured at the door, base against branch. OS_HOME=/proc/os-23858-home os start went from a hang (168,465 failing mkdir in 20 s) to exit 1 in 4.2 s, naming the path and ENOENT. The creatable control still reaches "Server is ready". os serve empty boot, os serve with an http OS_ARTIFACT_URL, os create --dir and os init under /proc each go from a hang to exit 1 in about 4 s.
  5. Pins and ablation.
    • Three pin files, each in bounded children: the CLI door pin (4 cases, 6.37 s), the CLI unit pin (6) and the runtime pin (8, 3.82 s).
    • One ablation put the recursive form back in both helpers. The door pin's two /proc legs and both helper pins then hit their bounds and were killed, while the controls stayed green. The restore was proven by blob and by a dist marker-absent check.
  6. Changeset, sentence by sentence. patch for @objectstack/cli and @objectstack/runtime, with Clause-②: no: a hang becomes an immediate refusal on paths that could never be created, so no input that succeeds today is refused. No new error code and no export.
  7. Contract review: none is owed. The paths decide no face, and Clause-②: no carries no arm. This seat's read is the review.
  8. Cross-lane, declared before this ACCEPT: scripts/check-cli-test-child-env.mjs (one population row, nine → ten, with its reason line) on domain:spec's post (6218991262).
  9. Gates, pinned to the head. Check-runs on a2ef81b41b read at this stamp, latest run per name: 41 names, 35 success, 6 skipped, 0 failure, nothing running. The successes include Build Core, Test Core 1–6/6 (the CLI integration tier and the runtime test:repo run there), Scaffold outside the monorepo, TypeScript Type Check and the four Type Check · lanes, all eight Lint · lanes, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard. The 6 skips are roster names: Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix), Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. The dev's local subset at this head: 87 of 87 derived gate commands exit 0 (--ran: 0 NOT-MEASURED, 0 UNRUN). The runtime local project exits 0 (6130 passed). The CLI unit tier: 302 / 304 files passed on d188c036d; the 2 files that pinned the replaced call were fixed in a2ef81b41 and re-run green. Both typechecks exit 0.
  10. Accepted deviations:
    • the gate's population row, declared above;
    • two existing CLI tests edited because they pinned the replaced call (remote-api-utils.test.ts, validate-build-gate-parity.test.ts);
    • one merge of origin/main, which brought one new site (dev-organization-ownership-upgrade.ts :128, classified out by construction);
    • the full CLI unit tier not re-run after the two-test fix (declared narrowing);
    • the container restart, with every unrecorded step re-run under the lock.

Seat ruling on open_questions[0] (one shared helper behind a new export): A. There is no new export. The walk stays one module-internal function per package. The real-need axis and the startup-scope axis both point there: no consumer outside these three packages asks for an fs helper, and an export of @objectstack/types/node would widen a published surface without pull. The two in-package copies (standalone-stack.ts makeSqliteDatabaseDirectory, dev.ts prepareDevDatabaseDirectory) fold onto each package's make-directory.ts in the follow-up that carries serve.ts :978 after #23743. That leaves three internal definitions with one semantics and no contract to version.

Carried by this seat on #23858, which stays open: serve.ts :978 (measured hanging: 2,019,800 failing mkdir in 45 s), after seat 1's #23743 lands, together with the two in-package folds. 承接者:无: the #23851 / #23696 copies accept any EEXIST on a created level, so a dangling symlink there reads as made; the later SQLite open still refuses, and there is no hang.

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

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

Development

Successfully merging this pull request may close these issues.

2 participants