feat(ship): recover original units without replacement work - #2351
Conversation
There was a problem hiding this comment.
Changes requested: Recovery flow is carefully fenced, but making RunnerOwnershipFence.claim/reserve throw mid-rebuild breaks existing callers that don't catch it during every periodic sweep.
Warning
Changes requested · head e1f12f7 · 2 findings: 1 minor, 1 nit
| Severity | Finding | Where |
|---|---|---|
| minor | F1 claim()/reserve() now throw during every sweep's ownership rebuild; existing uncaught callers (rebaseStep, legacy continuation reserve) turn that into 500s / uncaught refusals | src/core/runnerOwnership.ts:74 |
| nit | F2 recoveryTransport queried twice per resumed signal; listActiveRecoveries is an unindexed full-table scan run on every reclaim sweep | deploy/cloudflare-memory/worker.ts:3055 |
Full review
F1. RunnerOwnershipFence.claim and reserve now throw whenever recoveryComplete is false. Before this PR only the read-side owns/owner threw. The problem is timing: recover() sets recoveryComplete = false on every complete reclaim pass, not just at boot, and keeps it false across the awaits that rebuild ownership. Those passes come from guardCards inside startReclaimSweep, every LEASE_MS. The code this PR adds catches the throw, but at least two existing callers don't:
rebaseStep(adminCoordinator.ts:3803) callsdeps.runnerOwnership?.claim(...)bare. It escapes to the handler's catch-all and returns a generic 500internal error, which the driver can't recognise as a transient refusal.reserveLegacyOwnership(dispatcher.ts:1440) callsreserveoutside any try. A legacy continuation that lands mid-sweep becomes anuncaught/systemrefusal instead of the named "ownership could not be verified" reply.
Rarely, a rebase round or a continuation fails for no reason the user can see. Two possible fixes:
- Limit the throw to the initial boot rebuild.
- Wrap every existing
claim/reservecaller the wayowner()is wrapped already. Each should answerpublication_ownership_unknown, which this PR already added to the driver'sTRANSIENTset.
Also add a test for a rebase and a legacy continuation arriving while a sweep's recover() is in flight.
F2. In worker.ts (around line 3055), recoveryTransport runs its lookup twice: once in the condition and again in the spread. Compute it once, the way the finish path does. Separately, listActiveRecoveries scans and parses every coordinator_units row, and the reclaim sweep now calls it on each pass. Filtering in SQL on json_extract(json,'$.recovery') IS NOT NULL would keep that cost bounded as the table grows.
e1f12f7 to
01dafbf
Compare
Preserve original durable identity, publication ownership, budgets, and idempotency while a separate Workflow resumes only the proven findings or review checkpoint. Fail closed on moved or ambiguous evidence and prevent terminal recovery replies from creating replacement pipelines.
01dafbf to
a96d9cc
Compare
|
Current-head fix round at |
|
Re-review requested at a96d9cc — ownership-rebuild claim/reservation failures now return the named retryable refusal, repeated recovery lookups and historical-row scans are reduced, and both redundant CodeQL comparisons are removed while preserving cleanup. Focused regressions and Linux CI pass; all four prior findings have fix dispositions. Please verify the full ownership/CAS/cleanup boundary and original identity/budget guarantees at this exact head. |
There was a problem hiding this comment.
LGTM: Both prior findings are fixed: every fence claim and reserve call now returns the retryable publication_ownership_unknown refusal during a rebuild, and recovery lookups are indexed and done once.
Note
Approved · head a96d9cc · no findings
Full review
I found no new issues at a96d9cc, and both earlier findings are fixed:
- F1: while the ownership fence is being rebuilt, every call that claims or reserves a pull request now returns the retryable
publication_ownership_unknownrefusal instead of throwing. That includesrebaseStep(503) and the legacy-continuation reservation, which now gets a named refusal. - F2:
recoveryTransportis looked up once per signal.listActiveRecoveriesnow filters in SQL onjson_type(json,'$.recovery')instead of reading the whole table.
The test guard's only flag is the renamed test in deploy/cloudflare/coordinator.test.ts, and http-ingress.md allows it.
Switchboard can resume a proven terminal ship unit without inventing a replacement plan, budget, branch, or owner. It preserves the original durable identity while a separate Workflow transports the bounded recovery.
Why: #2265 exposed that reissuing a checkpoint silently creates nearby work instead of continuing the retained unit. The accepted owner record requires one owner and clock; this targets
mainindependently and does not stack on draft #1625.Where to look
findings/pr-checkfailure.Feedback wanted: Check the claim/rollback/CAS concurrency boundary and whether periodic ownership rebuilding remains retryable without weakening sole-owner enforcement.
Risk: 3,868 changed lines across recovery admission, runtime wiring, proofs, and docs. Splitting admission from runtime was rejected because either half alone breaks the atomic recovery contract. Roll back this commit to disable the command.
Verified: Current-head bot suites: 819 passed. TypeScript, ESLint, Prettier, specs, coverage, hygiene, vocabulary, user-message, and diff checks passed; Linux CI is green: 28 passed, 1 skipped.
Decisions (6)
recovery-<run>checkpoint drives the walk while every child and durable mutation retains the original instance/unit identity.publication_ownership_unknownrather than treating stale state as unowned.Validation (10 criteria)
request_changesresumes findings and re-review in the original namespacesrc/core/coordinator/driver.test.ts::runOriginalUnitRecoveryno_verdictstarts one read-only original-unit reviewsrc/core/coordinator/driver.test.ts::runOriginalUnitRecovery::continues no_verdict…src/channels/adminCoordinator.test.ts::…::reconstructs a failed original findings pr-check…src/channels/adminCoordinator.test.ts::POST /admin/coordinator/recover-unit — unchanged-head original-unit recovery::*publication_ownership_unknownand invokes no resolversrc/channels/adminCoordinator.test.ts::…::the runner's rebase returns the named transient while periodic ownership recovery is in flightsrc/core/dispatcher.test.ts::…::a legacy continuation returns publication_ownership_unknown when periodic ownership recovery starts before reservationdeploy/cloudflare-memory/runLedger.test.ts::…::lists only active recovery rows in SQL, ignores terminal history, and fails closed on malformed candidate JSON— passed in the Linux memory-worker gatesrc/core/dispatch/reattach.test.ts::carriedCoordinatorTag…;src/core/dispatcher.test.ts::executor provisioning by agent resources::*npx vitest run src/channels/adminCoordinator.test.ts src/core/dispatcher.test.ts src/core/runnerOwnership.test.ts src/core/refusal.test.ts— 819/819 passedspecs:check,specs:coverage --test-guard, hygiene, vocabulary, user-message, andgit diff --checkpassedFor agents
The physical recovery Workflow is not the durable owner: use
parentInstanceId + unitfor identity andtransportWorkflowIdonly for lifecycle delivery. Recovery reports intentionally describe checkpoint-local elapsed categories because legacy terminal rows do not retain a complete lifetime split. The memory-worker regression and full clean gate passed in Linux CI; no install mutated the shared local dependency state. The production #405 compatibility claim is based on captured durable-shape fixtures and fresh read-only facts; no live recovery was run before merge/deploy.