Skip to content

fix(cli): os migrate recorded-by, resume, account-issuer and apply rethrow oclif's exit signal, so a completed --json run prints one document and exits 0 - #21495

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21434-json-exit-signal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21434-json-exit-signal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21434

Clause-②: no

What was wrong

this.exit(n) throws oclif's exit signal (code: 'EEXIT', oclif.exit: n). When it runs inside a try, the try's own catch sees the signal first. Four migrate commands had a catch that reported whatever it caught, so the signal came back out as an error.

Measured at the public door on base aa4632235 (CLI run from source through bin/run-dev.js, a sqlite file with one sys_metadata_history row whose recorded_by is system):

command before (aa4632235) after (34a0cd121e)
os migrate recorded-by --apply --yes --json row converted; result document, then {"error":"EEXIT: 0","duration":1020}; exit 1 row converted; one document (JSON.parse of the whole stdout succeeds); exit 0
os migrate resume --run RUN_ID --json (run already concluded) refusal-shaped document, then {"error":"EEXIT: 0",…}; exit 1 one document; exit 0
os migrate resume --run RUN_ID --yes --json (journal row run_done removed) "no loaded package registers" document, then {"error":"EEXIT: 1",…}; exit 1 one document; exit 1

The merge of main after 34a0cd121e touched none of the four command files.

The fix

The existing idiom, if (isExitSignal(error)) throw error; (packages/cli/src/utils/format.ts), as the first statement of the swallowing catch in:

  • migrate/recorded-by.ts. 5 this.exit sites in the try, the completed-apply this.exit(0) among them.
  • migrate/resume.ts. 8 sites: resumed run, already-concluded run, unknown run id, plan not loaded, confirmation required, failed run.
  • migrate/account-issuer.ts. 2 sites: the refused pre-flight on the JSON face (second document {"error":"EEXIT: 1"}) and on the text face (extra EEXIT: 1 line).
  • migrate/apply.ts. 2 sites, text face only: the sys_account.issuer pre-flight refusals. Its JSON face returns before them.

There is no second helper and no producer elsewhere. The swallowing happens in each command's own catch, and nothing outside packages/cli/src/commands/migrate/ changes at runtime.

Census: every command that takes a JSON face, at 88ae5769c6

Derived from the oclif declarations. Each module under src/commands (the src/ twin of oclif.commands = pattern, ./dist/commands, **/*.js) is imported, and its default export's static flags is read, inherited flags included. A member declares a boolean json flag (32 commands) or a flag whose options include 'json' (--format json, 14 commands). That makes 46 commands with 105 this.exit-in-try sites.

  • In-family (4, fixed here): migrate recorded-by (5 leaking sites), migrate resume (8), migrate account-issuer (2), migrate apply (2, text face).
  • Already correct (14): cloud whoami (1 site), compile (21), build (inherits compile's 21), environments bind (2), environments create (1), migrate audit-metadata-bodies (2), migrate files-to-references (2), migrate multi-value-columns (4), migrate summary-nulls (2), migrate value-shapes (3), secret orphans (7), secret rewrap (6), validate (15), verify (1).
  • Not affected, no this.exit inside a try (28): cloud login, cloud logout, data create, data delete, data get, data query, data update, diff, environments list, environments show, explain, i18n check, i18n extract, info, lint, login, logout, meta delete, meta get, meta list, meta register, meta resync, migrate, migrate meta, migrate plan, register, storage orphans, whoami.

secret rewrap, which landed in #21469 while this branch was open, is already correct. Its 6 sites sit in the try at rewrap.ts line 195, whose catch (line 310) opens with the rethrow. #21469 added that line, so nothing here edits rewrap.ts. This PR adds no driven case for it: it is not in-family, and the structural half covers its exit path. json-stdout-purity.e2e.test.ts is not touched.

this.error(…) also raises a signal isExitSignal recognises. Measured: no JSON-capable command calls it inside a try. The only this.error calls inside a try are 2 in init.ts, which has no JSON face.

The enumeration pin: packages/cli/test/json-exit-signal.pin.test.ts (unit tier)

  1. Analyzer fixtures (15 cases). The detector is shown to fail on each shape it must catch: no rethrow, a catch with no binding, the rethrow not first, a second helper, isExitSignal imported from somewhere other than utils/format.js, an exit through a same-class helper or an arrow-function property, nested tries, an exit in an inner catch, an exit in a callback. It also passes the idiom, an unconditional rethrow, and try/finally.
  2. Structural, over the whole discovered population (46 cases + 3 meta). Every this.exit inside a try, direct or through a same-class method, must have the isExitSignal rethrow as the first statement of every enclosing catch. The check covers the command's own source and its superclasses' sources. The meta checks are: package.json's oclif command strategy is the one the walk mirrors; every walked module is a command; the population floor (46) and site floor (105); the named anchors; and that os build's chain reaches compile.ts.
  3. Driven, in-family completed paths (13 cases). recorded-by, resume and account-issuer run in-process through oclif with a preloaded Config. bootSchemaStack, the journal runner, the sentinel scan and the collision probe are replaced through vi.mock. Each case asserts one JSON document on stdout (a bare JSON.parse of the whole of it) and the exit status. The cases are recorded-by --apply --yes completed → 0, compensated → 1, failed → 1, and the confirmation refusal → 1. For resume: --run --yes completed → 0, compensated → 0, failed → 1; already concluded → 0; unknown id → 1; confirmation → 1; plan not loaded → 1. For account-issuer: ok → 0, refused → 1.

How a later command enters: there is no roster. A module under src/commands that declares either flag is in the population on the day it lands. secret rewrap is the first to have entered that way, and no line names it. The floors are the only hand-edited numbers. They catch a discovery or analyzer that silently returns zero.

Tier: unit, measured with tierOfFile: signals none. Nothing is spawned and nothing boots. The boot seam is replaced through vi.mock and never value-imported. Cost: about 23 to 26 s of import at collection on a shared box, outside any clocked window. A single-command file in the same package (recorded-by.test.ts) imports in 15.7 s on the same box, so the all-commands discovery adds about 10 s.

Evidence

  • Pin at 88ae5769c6: vitest run --project unit test/json-exit-signal.pin.test.ts gives 77 passed (77), VERDICT command-exit 0.
  • Ablations at 9490dd3d75, before the merge. Both mutations went through scripts/ablation-replace.mjs in a trap-restoring script, and the expected direction was red.
    • recorded-by.ts rethrow replaced. The tool's literal count went 1 → 0 for the anchor and 0 → 1 for the marker, and the blob went e74172d2 → 31085626. Result: 5 failed / 71 passed. The failures are the structural member (5 sites "swallowed by the catch at line 196") and the 4 driven recorded-by cases, which show stdout carried 2 JSON documents … {"error":"EEXIT: 0","duration":1}. Restored: blob == HEAD and git diff HEAD empty.
    • resume.ts rethrow replaced. Anchor 1 → 0, blob 6e2c7de0 → 2cfe0d4c. Result: 8 failed / 68 passed (structural member + all 7 driven resume cases). Restored: blob == HEAD.
    • The hand grep -c of the first marker inside the wrapped command read 0. That marker contains *, which grep reads as a regex, so the hand count is void. The tool's literal before/after counts and blob hashes are the evidence that the mutation landed.
  • Unit tests that reach the changed commands, at 88ae5769c6: the pin, recorded-by.test.ts, multi-value-columns.no-auto-run.test.ts, artifact-boot-migration.test.ts, format.exit-code.test.ts and schema-migration-plugins.test.ts gave 6 files, 134 tests passed, VERDICT command-exit 0.
  • Typecheck at 88ae5769c6: pnpm --filter @objectstack/cli typecheck gave VERDICT command-exit 0. tsc --noEmit passed. check:test-typecheck is OK with its ledger unchanged (3 files / 28 errors / 6 pinned signatures).
  • Gates at 88ae5769c6: all 65 families from node scripts/pm/dispatch-gates.mjs --commands exit 0. --ran reconciliation: 65 derived, 65 run, 0 NOT-MEASURED. That zero is derived, because every line carries its exit code. check:i18n, check:i18n-coverage and check:i18n-walk-parity first answered exit 3 (prerequisite: CLI not built) and are green after building their declared closure. check:dual-build-cjs-loads first answered exit 3 (6 unrelated packages had no dist/) and is green after building them.
  • Lint, as a proven narrowing. ESLint over the 5 changed .ts files with --no-inline-config --format json read 5 files, 0 errors, 0 warnings. The population comes from ESLint itself: isPathIgnored is false for all 5 and calculateConfigForFile resolves rules for each. Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules, stated at line 327), so this diff cannot move the verdict on any untouched file. The full pnpm lint is CI's.

NOT MEASURED

  • packages/cli full unit tier, not measured locally. Two attempts at vitest run --project unit were cut off: exit 137, then a container restart mid-run. It is narrowed to the 6 unit files above, which import or reach the four changed commands. CI runs the whole tier.
  • packages/cli integration tier, declared to CI. The diff touches no integration-tier file and no spawn entry. artifact-boot-migration.unbuildable-index.test.ts and sqlite-occupancy.test.ts reach the changed commands and live in that tier.
  • Nightly e2e (json-stdout-purity, migrate-exit-code): not run. They drive only the bare --json forms, which never reached the defect.
  • Public door, os migrate account-issuer --json refusal. It needs a stack that registers sys_account (plugin-auth's object) with colliding rows. The plain project stack does not register it, so the door answers a read refusal before the path is reached. Measured at 88ae5769c6: {"error":"Cannot enumerate sys_account: …"}, one document, exit 1. The driven unit case covers the refusal path.
  • Public door, os migrate resume --run … --yes completed. It is unreachable at the door today; see the first acceptance note. The driven unit case covers it.

Acceptance notes

  • os migrate resume --run RUN_ID --yes cannot resume any run at the public door. MigrationRecoveryPlugin, which owns the migration-plans registry, is exported from @objectstack/runtime but composed nowhere in packages/cli. So every interrupted run answers "belongs to plan …, which no loaded package registers … Load the package that owns this migration". That holds even for metadata.recorded-by-sentinel-to-null, whose owner @objectstack/metadata-protocol the CLI itself loads. recorded-by's in-process plans.register(plan) lands in the no-registry catch for the same reason. Measured above, and reported to the seat as a separate finding.
  • On an already-concluded run, os migrate resume --run RUN_ID --json exits 0 but puts its message under the error key. That is unchanged here, and noted only.
  • The same catch shape sits on three commands with no JSON face: os package install, os package publish and os plugin sign. Measured: os package install ./does-not-exist.json prints ✗ Cannot read artifact: …, then ✗ EEXIT: 1, and exits 1. The exit status is right and the extra line is wrong. These files are outside this claim's declared file surface, which covers JSON-face commands, so they are not touched here. They are reported to the seat as a finding.

Generated by Claude Code

claude added 6 commits October 2, 2026 22:50
…reporting it

`os migrate recorded-by --apply --yes --json` printed its result, then a
second document `{"error":"EEXIT: 0"}`, and exited 1 after a completed run:
the `this.exit(…)` inside the command's `try` throws oclif's exit signal, and
the `catch` reported it as an error. `os migrate resume --run` (resumed or
already-concluded run), `os migrate account-issuer --json` (refusal) and the
text face of `os migrate apply` (account-issuer pre-flight refusal) had the
same catch. Each catch now opens with the existing idiom,
`if (isExitSignal(error)) throw error;`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added the size/l label Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/data-modeling/drivers.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/cli.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/index.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/deployment/self-hosting.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/kernel/services-checklist.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/upgrading.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-3.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/releases/v17/17-5.mdx (via os migrate account-issuer (command, read off packages/cli/src/commands/migrate/account-issuer.ts), os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))
  • content/docs/releases/v17/17-6.mdx (via os migrate apply (command, read off packages/cli/src/commands/migrate/apply.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see

Coarse fallback — 27 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 9ff74285f14b7b60699546211e53e9ccc13c0d61 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9ff74285f14b7b60699546211e53e9ccc13c0d61

⚠️ 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 9ff74285f14b7b60699546211e53e9ccc13c0d61 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 00:47
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 00:48
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 2ee8383 Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21434-json-exit-signal branch October 3, 2026 01:14
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