specs rev 4: answer Astra round 3 (fence resumption, cleanup-pending journal phase, reserve-before-upload, sweep expires queued runs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+131
−24
docs/superpowers/specs/2026-10-08-carlos-run-design.md+12 −1docs/superpowers/specs/2026-10-08-seeded-instances-design.md+76 −23docs/superpowers/specs/CODEX-FINDINGS-RUN-AND-SEEDED-R3.md+43 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-10-08-carlos-run-design.md b/docs/superpowers/specs/2026-10-08-carlos-run-design.md| index 229c7d7c3..6cc22fa02 100644 |
| --- a/docs/superpowers/specs/2026-10-08-carlos-run-design.md |
| +++ b/docs/superpowers/specs/2026-10-08-carlos-run-design.md |
| @@ -1,6 +1,6 @@ |
| # `carlos run`: a member runs an app command against one instance (#259) |
| -**Status:** rev 3, 2026-10-08. Rev 3 answers round 2 (§12). Rev 1 (c66792a3) was approved by Paul. |
| +**Status:** rev 4, 2026-10-08. Rev 3 answered round 2 (§12); rev 4 answers round 3 (§13). Rev 1 (c66792a3) was approved by Paul. |
| Rev 2 answers Astra's round 1 (`CODEX-FINDINGS-RUN-AND-SEEDED-R1.md`; |
| findings 1 to 7 are this spec's). Each change is marked **(codex N)**, and |
| §11 lists the dispositions. |
| @@ -397,6 +397,10 @@ and prints every chunk not yet acknowledged. |
| - **The console sweep.** It runs in the existing deadline-sweep loop and: |
| - deletes any run whose result is more than 24 hours old, whether or not |
| it was acknowledged; |
| + - expires any run still `queued` 60 seconds past its request (codex |
| + R3-3). It CASes `queued` to `expired` (losing to a pickup is fine), |
| + writes the `run-end` audit row as `expired`, and clears it. So a run |
| + whose caller disconnected never blocks the instance; |
| - deals with any run still `picked` or `cancel` an hour past its |
| deadline: it marks the run `done` with the audit end `lost`, and |
| clears it. |
| @@ -475,6 +479,7 @@ into the code. |
| - a run queued before the box owned the host is picked up; |
| - the pickup CAS with two boxes racing; |
| - expiry winning over a late box, and a late pickup winning over expiry; |
| + - the sweep expiring a queued run whose CLI went away; |
| - cancel before pickup never launching; |
| - a request whose incarnation doesn't match the record being refused; |
| - run, move and import abort racing on the record's ETag; |
| @@ -540,3 +545,9 @@ switch. Tito's boxes need the nested layout before Doc and Vicky can use it |
| | R1-3, still open: no incarnation | **Accepted.** §5.1: the request carries the record's incarnation, and recreating the host drops `ActiveRun` | |
| | R2-2 (P1) run gets past the import gate | **Accepted.** §5.1 step 9: refused while an import is uncommitted, checked under the record CAS and again at launch | |
| | R2-6 (P2) "not picked up" leaves a runnable request | **Accepted.** §5.2 and §5.7: queued, picked, cancel, done, cancelled and expired, all moved by CAS on the record; expired and cancelled are terminal before launch | |
| + |
| +## 13. Round 3 dispositions |
| + |
| +| Finding | Disposition | |
| +|---|---| |
| +| R3-3 (P2) a disconnected caller leaves the run queued | **Accepted.** §5.8: the console sweep expires queued runs 60 seconds past the request, by CAS against pickup | |
diff --git a/docs/superpowers/specs/2026-10-08-seeded-instances-design.md b/docs/superpowers/specs/2026-10-08-seeded-instances-design.md| index 0f646a63a..3293a8267 100644 |
| --- a/docs/superpowers/specs/2026-10-08-seeded-instances-design.md |
| +++ b/docs/superpowers/specs/2026-10-08-seeded-instances-design.md |
| @@ -1,6 +1,6 @@ |
| # Instances that start from a database: `--from` (#260) and the import door (#261) |
| -**Status:** rev 3, 2026-10-08. Rev 3 answers round 2 (§12). Paul approved rev 1 (c66792a3). Rev 2 |
| +**Status:** rev 4, 2026-10-08. Rev 3 answered round 2 (§12); rev 4 answers round 3 (§13). Paul approved rev 1 (c66792a3). Rev 2 |
| answers Astra's round 1 (`CODEX-FINDINGS-RUN-AND-SEEDED-R1.md`; findings |
| 8 to 20 are this spec's). Each change is marked **(codex N)**, and §11 lists |
| the dispositions. |
| @@ -71,11 +71,15 @@ bucket, beside the `instances/` and `moves/` prefixes. |
| instance that nobody wakes for a week still needs its snapshot. Instead, |
| the console's deadline sweep deletes `seeds/<host>/` only in three |
| cases: |
| - - no record names it; |
| + - no record names it, and the snapshot is more than an hour old. Every |
| + door writes its record **before** the snapshot (§4 step 7, §5.1), so a |
| + snapshot without a record is from a door that died between the two |
| + writes (codex R3-2); |
| - the record's seed is `placed` or `committed` and has another ID; |
| - it belongs to an import stuck in `awaiting-upload` or `uploaded` for |
| - more than 7 days, which the sweep aborts first (§5.6), a terminal |
| - transition. |
| + more than 7 days, or to a `--from` stuck in `reserving` for more than an |
| + hour. The sweep moves the record to the terminal `aborted` by CAS |
| + first, and deletes nothing unless that CAS succeeds (§5.6). |
| ### 3.2 The record |
| @@ -105,7 +109,7 @@ type SeedSource struct { |
| | Door | Path through the states | |
| |---|---| |
| -| `--from` | `ready` → `placed` | |
| +| `--from` | `reserving` → `ready` → `placed` | |
| | import | `awaiting-upload` → `uploaded` → `ready` → `swapped` → `placed` → `committed` | |
| An import can also leave the path: |
| @@ -120,7 +124,7 @@ other per-record handling: |
| | State | Reconcile | |
| |---|---| |
| | `aborted` | runs the idempotent rollback (§5.6), or with no journal row deletes `seeds/<host>/` and the record | |
| -| `awaiting-upload`, `uploaded`, `refused` | skips the record; nothing is minted | |
| +| `reserving`, `awaiting-upload`, `uploaded`, `refused` | skips the record; nothing is minted | |
| | `ready` | mints (`--from`) or swaps (import) | |
| | `swapped`, `placed`, `committed` | ordinary handling, plus the import gate (§5.4) while not `committed` | |
| @@ -267,10 +271,19 @@ Both doors call it. |
| directory under the console's scratch, `<console data>/scratch/seeds/`. |
| Run `CheckDBContext` under the same deadline. This never reads the live |
| file. |
| -7. **Write.** Write `seeds/<new>/snapshot.db` with `PutFrom` from the |
| - restored file (seekable, under S3's 5 GB single-PUT limit). Then write |
| - the record with seed `ready`, kind `replica`, the source and the |
| - restored TXID. |
| +7. **Write** (codex R3-2), in this order: |
| + 1. **Reserve the record.** This happens before the restore, right after |
| + step 3. Create the record with seed `reserving`, kind `replica`, its |
| + seed `ID` and the source. Nothing mints a `reserving` record, and the |
| + sweep leaves its snapshot alone (§3.1). |
| + 2. **Upload.** Write `seeds/<new>/snapshot.db` with `PutFrom` from the |
| + restored file (seekable, under S3's 5 GB single-PUT limit). |
| + 3. **Publish.** CAS the record from `reserving` to `ready`, with the |
| + restored TXID, sha256 and bytes. |
| + |
| + If the door fails after step 1, it CASes the record to `aborted`, |
| + which the box (or the sweep) cleans up as for an import that never |
| + swapped. |
| **Cleanup.** The scratch is deleted on every exit path: success, refusal, |
| error, deadline, and client disconnect. The console also empties |
| @@ -469,22 +482,46 @@ journal's state says where to resume after a restart: |
| From here on the edge refuses even verify requests, and that survives |
| an agent restart. |
| -2. **Fence and stop** (codex R2-4). Call |
| - `activator.FenceAndStopIfUnfenced(host, generation)` |
| - (`internal/activator/maintenance.go:45-60`) and require complete stop |
| - evidence (`stopObservation.complete`: lease released, main and sidecar |
| - stopped and exited, replica closed). On `ErrStopUnproven` or |
| - `ErrFenceHeld`, keep the fence and try again on the next pass. After a |
| - restart, a `rolling-back` journal row re-installs the fence before |
| - anything else. |
| +2. **Fence and stop** (codex R2-4, R3 open). This follows the layout |
| + pass's handling of the same API (`internal/layout/pass.go:654-680`): |
| + - **Ours already?** If `FenceGeneration(host)` equals the journal's |
| + fence generation, the fence is this rollback's own, from an earlier |
| + pass. Carry on with it. |
| + - **Otherwise,** call `FenceAndStopIfUnfenced(host, generation)`. It |
| + refuses any fence that already exists, including one from an earlier |
| + attempt (`maintenance.go:103-106`), which is why the check above |
| + comes first. |
| + - **`ErrFenceHeld`** for a fence that isn't ours means another |
| + operation holds the host. Defer to the next pass. |
| + - **`ErrStopUnproven`.** A cold host has no live entry, and a host |
| + never woken has no stop history (`maintenance.go:113-121`). Proceed |
| + only if all of these hold, as the layout pass does: |
| + - the box swept its instances at agent start; |
| + - `Quiesced(host)` is true; |
| + - `StopUncertain(host)` is false. |
| + |
| + Otherwise lift this attempt's fence and defer. |
| + |
| + Deferring is safe. The closed gate from step 1 still refuses every |
| + request, ticks are skipped for a gated row, and runs are refused, so |
| + nothing can wake the instance before the next attempt. |
| 3. **Remove the local data** through `instancefs`: the database, `-wal`, |
| `-shm`, `<db>.seed` and litestream's local metadata (codex R2-1). |
| 4. **Restore the route.** In one transaction, put the journal's saved row |
| - back exactly as it was, and delete the journal row. |
| + back exactly as it was, and set the journal row to `cleanup-pending` |
| + (codex R3-1). |
| 5. **Lift the fence.** |
| 6. **Clean the bucket.** Delete the host's `instances/` replica, if any, |
| and `seeds/<host>/`. |
| 7. **Delete the record**, by CAS, if it still names this seed `ID`. |
| +8. **Delete the journal row.** |
| + |
| +**After a restart in `cleanup-pending`,** the box resumes at step 6. It |
| +doesn't swap, fence or stop anything: the route is the restored legacy |
| +row, and its process is the app's own. Until the journal row is gone, the |
| +host's leftover replica is this box's to delete. So a retried import never |
| +meets the creation gate's "replica data remains" refusal |
| +(`apiinstances.go:1146`). |
| **Why abort loses nothing.** Under the gate, nothing but verify requests |
| reached the instance, so it holds no writes the old process never saw. |
| @@ -499,15 +536,16 @@ The journal is a box-local registry table, `import_journal`, replacing rev |
| - seed `ID`; |
| - the original row as JSON; |
| - record key; |
| -- state: `swapped` or `rolling-back`; |
| +- state: `swapped`, `rolling-back` or `cleanup-pending`; |
| - fence generation; |
| - time. |
| Reconcile reads it **before** the bucket records, on every pass and at |
| agent start, and acts on it per §5.4 and §5.6. |
| -A journal row is deleted only in the same transaction that clears the gate |
| -at commit, or that restores the original row at rollback. So while an |
| +A journal row is deleted either in the same transaction that clears the |
| +gate at commit, or as the last step of a rollback, after the bucket and the |
| +record are clean. So while an |
| import is in flight, the box always knows what it replaced, whatever has |
| happened to the record. |
| @@ -665,7 +703,14 @@ into the code. |
| or deleted outright; |
| - the rollback fence: a verify request during rollback is refused, stop |
| evidence is required, and the fence is re-installed after a restart; |
| - - an idempotent rollback across a crash between any two of its steps. |
| + - an idempotent rollback across a crash between any two of its steps, |
| + including between the route restore and the replica delete |
| + (`cleanup-pending` resumes, and never stops the legacy process); |
| + - the fence: resumed when it is ours by generation; deferred when it is |
| + another operation's; for a never-woken host, accepted only when the |
| + box was swept at start and the host is quiesced; |
| + - `--from`'s `reserving` record: the sweep never deletes its snapshot, |
| + and abandons it by CAS only after an hour; |
| - **The gate:** |
| - the edge and the door answer 503 without the token and pass with it; |
| - no ticks while gated; |
| @@ -750,3 +795,11 @@ remaining imports wait for §3.4's claim algorithm. |
| | R2-4 (P2) a verify wake during rollback | **Accepted.** §5.6: the gate is closed durably first, then `FenceAndStopIfUnfenced` with complete stop evidence; the fence is re-installed after a restart | |
| | R2-5 (P1) the lifecycle rule deletes needed seeds | **Accepted.** §3.1: no lifecycle rule; a console sweep deletes only unreferenced or superseded snapshots, after a terminal abort for stale imports | |
| | R2-7 (P2) delete bypasses rollback | **Accepted.** §5.6: delete is refused for an uncommitted import; rollback is driven by the journal, including when the record is gone | |
| + |
| +## 13. Round 3 dispositions |
| + |
| +| Finding | Disposition | |
| +|---|---| |
| +| R2-4, still open: the fence retry can't work | **Accepted.** §5.6 step 2: the layout pass's pattern; our own fence is recognised by the journal's generation, and a cold host's stop is accepted only when the box was swept at start and the host is quiesced | |
| +| R3-1 (P2) rollback forgets replica cleanup | **Accepted.** §5.6 steps 4 to 8: the journal stays as `cleanup-pending` until the bucket and record are clean; resuming never swaps or stops | |
| +| R3-2 (P2) sweep deletes a snapshot mid-create | **Accepted.** §4 step 7: the record is reserved before the upload; the sweep never deletes a snapshot named by a live record, and abandons a stale reservation by CAS first | |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-RUN-AND-SEEDED-R3.md b/docs/superpowers/specs/CODEX-FINDINGS-RUN-AND-SEEDED-R3.md| new file mode 100644 |
| index 000000000..28a5aa62f |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-RUN-AND-SEEDED-R3.md |
| @@ -0,0 +1,43 @@ |
| +| Earlier finding | Disposition at `e053a333` | |
| +|---|---| |
| +| R1-1 — Run/move admission race | **Fixed.** Run §5.1 puts both admissions on the instance record’s ETag and requires guards to be reapplied after a conflict. | |
| +| R1-3 — Missing incarnation | **Fixed.** Run §§5.1–5.2 bind the request to the record incarnation and check it before launch. | |
| +| R1-10 — Protected route columns | **Fixed.** Seeded §5.4 explicitly refuses `Backing`, `Tunnel`, `Caps`, `Platform` and `Canary`. | |
| +| R1-15 — Abort safety outside HTTP | **Fixed.** The import gate, commit boundary and run refusal together cover the previously identified paths. | |
| +| R1-18 — Historical timestamps | **Declined with an acceptable reason.** Seeded §4 limits this version to TXIDs; timestamp support is explicitly excluded. | |
| +| R1-19 — Scratch bounds | **Fixed at spec level.** Seeded §4 names decoded-size admission, a shared reservation pool, shared concurrency limits and cancellation on overgrowth. | |
| +| R2-1 — Local database survives abort/re-import | **Fixed for the reported local-data defect.** Seeded §§3.4 and 5.6 require matching installation identity and removal of local database artifacts. A distinct remote-cleanup defect appears below. | |
| +| R2-2 — Run bypasses import gate | **Fixed.** Run §5.1 refuses uncommitted imports under the shared CAS and requires another check at launch. | |
| +| R2-3 — Crash between swap and record CAS | **Fixed.** Seeded §§5.4 and 5.7 consult the identity-bound journal before applying legacy-row predicates and resume the record transition. | |
| +| R2-4 — Verification wake during rollback | **Not fixed as an implementable rollback.** The durable gate closes admission, but §5.6’s retry instruction cannot work with the selected API: `FenceAndStopIfUnfenced` refuses **any** existing fence, including its own previous attempt ([maintenance.go:103](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/activator/maintenance.go:103)). A never-woken import also lacks stop history and returns `ErrStopUnproven` ([maintenance.go:114](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/activator/maintenance.go:114)); every subsequent attempt then returns `ErrFenceHeld`. **Fix:** define resumption of the journal’s own fence and cold/restarted stop proof. The existing layout caller handles these distinctions explicitly ([pass.go:654](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/layout/pass.go:654)). | |
| +| R2-5 — Seven-day expiration deletes needed seeds | **Fixed for the reported expiration defect.** Seeded §3.1 removes unconditional expiration. The replacement sweep introduces a separate publication race below. | |
| +| R2-6 — Reported pickup failure leaves executable work | **Fixed.** Run §5.2 requires successful terminal expiry before reporting “not picked up”; §5.7 makes queued cancellation terminal. | |
| +| R2-7 — Instance deletion bypasses rollback | **Fixed.** Seeded §5.6 refuses deletion after import admission becomes actionable, and journal recovery does not depend on the record remaining present. | |
| + |
| +1. **P2 — Seeded §§5.6–5.7: rollback forgets unfinished replica cleanup.** |
| + |
| + Step 4 restores the original route and deletes the journal; step 6 deletes the remote replica. Crash between them. On restart, the surviving `aborted` record has no journal, so §§3.2 and 5.6 direct the box to delete only the seed snapshot and record. Nothing resumes deletion of the replica. |
| + |
| + Record deletion deliberately preserves replica data ([provision.go:1840](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/provision/provision.go:1840)). The ordinary creation gate then refuses reuse of that hostname because replica data remains ([apiinstances.go:1146](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/console/apiinstances.go:1146)). Thus an interrupted abort leaves the conversion unable to retry through the ordinary creation checks. |
| + |
| + **Fix:** retain the journal in a `route-restored/cleanup-pending` phase until remote cleanup and record retirement finish. Recovery from that phase must neither repeat the swap nor stop the restored legacy process. |
| + |
| +2. **P2 — Seeded §§3.1, 4 step 7: the sweep can delete a snapshot during successful creation.** |
| + |
| + `--from` writes the snapshot before writing its record. The new sweep deletes snapshots when “no record names it.” A sweep between those two writes therefore deletes the snapshot, after which creation can successfully publish `ready`. Its first wake has neither a replica nor the required snapshot and must fail. |
| + |
| + These are independent conditional object writes: `PutFrom` conditions only the snapshot key ([store.go:800](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/release/store.go:800)); `CreateRecord` separately creates the record key ([provision.go:845](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/provision/provision.go:845)). Neither protects the interval from deletion. |
| + |
| + **Fix:** reserve the seed identity durably before uploading, using a non-minting record state or publication intent that the sweep honors. Retire abandoned reservations through an explicit terminal transition before deleting their objects. |
| + |
| +3. **P2 — Run §§5.2, 5.8: a disconnected caller can leave queued work open indefinitely.** |
| + |
| + Let the console accept a run while its box is unavailable, then disconnect the CLI before its 60-second expiry request. Only the CLI initiates `queued → expired`. The sweep handles overdue `picked`/`cancel` runs and terminal runs, but never `queued`. Consequently `ActiveRun` remains non-terminal indefinitely and blocks subsequent runs and moves. |
| + |
| + The selected persistence mechanism is a conditional object write, with no expiry behavior ([store.go:767](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/release/store.go:767)). Existing record transitions likewise occur through explicit mutations ([provision.go:1129](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/run-and-seeded/internal/provision/provision.go:1129)); storing `Deadline` does not perform a transition. |
| + |
| + **Fix:** assign overdue queued-run expiry to the console sweep, independently of the CLI. It must CAS `queued → expired` against pickup, publish the terminal result and audit outcome, and release the reservation. |
| + |
| +`carlos run`: **revise**. |
| + |
| +Seeded instances: **revise**. |
| \ No newline at end of file |