fix(cli): exit cleanly on startup migration refusal#110207
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 17, 2026, 8:29 PM ET / July 18, 2026, 00:29 UTC. Summary PR surface: Source +9, Tests +122. Total +131 across 2 files. Reproducibility: yes. at source level: the added hermetic child process creates a real unresolved plugin-state conflict and verifies the gateway exit status, output cardinality, and lease cleanup. The reporter’s exact native SIGSEGV environment was not reproduced. Review metrics: none identified. Stored data model Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Keep the owner-side typed-exit design, rebase onto current Do we have a high-confidence way to reproduce the issue? Yes, at source level: the added hermetic child process creates a real unresolved plugin-state conflict and verifies the gateway exit status, output cardinality, and lease cleanup. The reporter’s exact native SIGSEGV environment was not reproduced. Is this the best way to solve the issue? Yes: routing deliberate readiness refusals through the existing typed exit contract at their owner preserves actionable output while leaving unexpected preflight errors on the generic entry failure path. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against dc0285366efc. Label changesLabel justifications:
Evidence reviewedPR surface: Source +9, Tests +122. Total +131 across 2 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (3 earlier review cycles)
|
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
fd057e7 to
5449ab1
Compare
5449ab1 to
d0281e2
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
Merged via squash.
|
Co-authored-by: VACInc <[email protected]>
Summary
ExitError(1)soensureConfigReadyuses its existing typedruntime.exitpathDatabaseSyncmigration/checkpoint paths for throw-time handle leaks; every direct handle is already closed byfinally, so no unrelated migration refactor is includedThis PR was originally stacked on #110200. That prerequisite has now merged as
748fc044192a1c98d8344ea6682a3f38dd7ef384; the branch was rebased ontoorigin/mainat82fa956d5a22c65d0dd9f2bfc5ff964e45c9728e, and the already-appliedce6e200aaa2commit dropped from this PR's history. The landing dependency is satisfied.Refs #109831.
What Problem This Solves
Startup migration, invalid-config, and plugin-verification refusals were ordinary
Errors. They bypassed the typed exit handling insrc/cli/program/config-guard.ts, reached the genericsrc/entry.tsfailure handler, and ended in its directprocess.exit(1)path. In the reported supervised deployment, that path terminated with signal 11 / status 139 instead of a clean refusal status, turning a fail-closed migration result into a supervisor crash loop.Root Cause
The proven pre-fix control flow is:
src/commands/doctor-config-preflight.tsformatted an intentional readiness refusal but threw a plainError.ensureConfigReadyonly convertsExitErrorintoruntime.exit(error.code), so it rethrew the plain refusal.src/entry.tsaddedCould not start the CLI./Reason:formatting and calledprocess.exit(1).The preflight's existing
finallyalready releases its heartbeat and startup migration lease before the error leaves the owner. The fix preserves that unwind and hands the intentional refusal to the typed caller contract instead of treating it as an unexpected CLI startup failure.The reporter's exact native signal source is not established without their core dump. This change fixes the incorrect refusal path and locks down the observable process contract; it does not claim to identify or patch the native module.
Why This Change Was Made
The refusal owner must print the reason because the typed
ExitErrorbranch intentionally does not format errors, while the generic entry catch is no longer reached. Printing once immediately before throwingExitError(1)preserves the complete operator guidance without duplicating the generic wrapper.This is the narrow owner-boundary fix: unexpected preflight errors still use the generic failure handler, and gateway readiness remains fail-closed.
The core migration audit covered the direct
DatabaseSyncconstructors insrc/infra/state-migrations.debug-proxy.ts,src/infra/state-migrations.task-sidecar-rows.ts, andsrc/infra/state-migrations.storage.ts, plus the checkpoint database wrapper used bysrc/infra/startup-migration-checkpoint.ts. Each closes infinally, including throw paths. No plugin-owned code was changed.User Impact
When a startup migration gate refuses readiness, operators retain the full actionable refusal text exactly once. The CLI exits through the typed status-1 path, the migration lease is released, and supervisors see a normal non-zero exit rather than a signal in the covered regression environment.
Evidence
Rebase result
After #110200 merged,
git fetch origin mainandgit rebase origin/mainskipped its already-appliedce6e200aaa2commit and replayed only this PR's fix. Main advanced once during the proof window, so the branch was rebased a second time before the final proof.git log --oneline origin/main..HEADon the published branch contains one commit:Final identities:
Real behavior proof — exact rebased head
Environment: Linux source checkout, Node
v25.9.0, actualsrc/entry.ts, no provider/channel credentials, and an isolated realpathed temporaryHOME, config, andOPENCLAW_STATE_DIR. The canonical plugin-state row had timestamp 2000 and value{"version":1}; the legacy sidecar held the same key with newer timestamp 3000 and divergent value{"version":2}. That is intentionally unresolved under the merged #109832 migration contract.A Node supervisor wrapper used
spawnSyncwith a 60-second timeout and captured the wait status and signal separately. It launched the actual gateway entry twice against the same unresolved state:Observed on exact head
d0281e2cc2a852f2c20903275fa7fa74ac996c92:Redacted stderr from the first run:
This proves both supervisor-observed gateway attempts exited with code 1 rather than a signal/status 139, printed the refusal headline exactly once on stderr, omitted the generic outer-entry wrapper, and released the startup migration lease. The second run acquired a fresh lease and did not report a stale/active lease.
Doctor-path parity
The same exact-head wrapper and unresolved fixture then ran:
Observed:
Doctor continues to report the unresolved migration without gating gateway readiness, exits normally, and releases its lease. Gateway remains fail-closed on the next startup attempt.
Guard-boundary verification
The existing
ensureConfigReadycatch insrc/cli/program/config-guard.tsinterceptsExitErrorand callsruntime.exit(error.code). The live process transcript proves the refusal reaches that branch: the generic[openclaw] Could not start the CLI.wrapper count is zero, while the refusal owner prints its reason exactly once.Post-rebase fixture correction
The first focused run after rebasing failed because merged #109832 correctly archived the old fixture's older sidecar row; the child then continued to a locally occupied gateway port. That was a stale test premise, not a product regression. The fixture now uses a newer divergent sidecar timestamp, which is the migration state that must remain unresolved. The corrected exact-head run is green.
Mandatory uncommitted autoreview of that fixture correction reported:
Pre-fix contrast and native-crash boundary
An earlier run from pre-fix merge base
433b3ce86d71a88fb34d4f61f6f10c36b912aeeeexited status 1 with no signal on this host, but includedgeneric_wrapper_count=1; the patched path hasgeneric_wrapper_count=0. The reporter's SIGSEGV did not reproduce in this environment on either revision. This proof establishes the clean typed-exit contract owned by this PR; identifying the reporter's crashing native module remains deferred pending their core dump.Verification
What Was Not Tested
runGatewayCommandstarts runtime services.