Skip to content

Backend: replay a capture after client restart (cold restart across process death) - #267

Merged
SamGu-NRX merged 1 commit into
alphafrom
obv/burst6-bs-cold-restart-replay
Oct 16, 2026
Merged

SamGu-NRX merged 1 commit into
alphafrom
obv/burst6-bs-cold-restart-replay

Conversation

@obvious-autobuild

Copy link
Copy Markdown

Human author: Sam Gu (sgu07966@gmail.com)

Summary

PR #262 closes the loop on a lost response, but its resume runs inside one Python process: the client that retries is the same interpreter that lost the reply, so it still holds whatever it cached. A phone does not get that luxury — the app dies, relaunches, and must recover from what it persisted and what the server remembers.

This PR adds backend/scripts/capture_cold_restart.py: one repeatable command that splits the client across two fresh Python processes against a real create_app() server (local uvicorn, memory state, local volume storage, the documented dev handoff HSS_INGEST_HOOKS=auto with orchestrator stage stubs — no GCP, no Modal, no network, no credentials):

  • primary client — replays a saved capture_flow_e2e receipt (create, register, PUT, commit, finalize) and dies at a scripted interruption: a lost create/commit/finalize response (*-lost) or a stop before commit/finalize (pre-*). It persists only what a device could: a journal of what it sent and which replies it actually saw.
  • resume client — a fresh process with fresh in-memory state. It reads the receipt and the journal and recovers through the real routes: create again with the saved idempotency key, re-register to learn the server's file states instead of trusting its own, re-send the identical commit body, and finalize. When the dead process already finalized, the capture is closed to files and commits (the server 409s both), so recovery goes straight through the finalize's idempotent replay — the stored run id — and never re-registers.

Across the restart it asserts the things a raw HTTP log cannot prove:

  • one capture id — the resume create answers 200 from the idempotency store (never a second 201) with the capture id the dead process recorded, and a final create after the verdict still maps to the same capture;
  • one deterministic run id — run_ + sha256(captureId + NUL + packet bytes), unchanged on a second finalize, recovered even when the dead process never saw it;
  • the committed hashes — the commit reports the receipt's paths and no mismatch (or, on the finalize-replay path, the server's status count shows every file committed);
  • one terminal state — one terminal status, /result served once for that run with the matching outcome, and exactly one terminal verdict_ready event.

Nothing retries forever: server readiness, per-leg subprocess, per-request, and terminal/event polling are all bounded. A receipt written before the replay fields existed fails before anything is sent, naming the missing field.

Test evidence

backend/tests/test_capture_cold_restart.py (14 tests):

  • the CLI runs as a subprocess for all five interruption points; each run's report proves the primary and resume legs were different fresh processes sharing only the receipt, journal, and server;
  • per-point coverage: commit-lost rebuilds state from the server (re-register reports all committed, identical commit body commits again); finalize-lost recovers the run id the dead process never saw via the finalize-replay path; create-lost recovers a capture id that never reached the client; pre-commit uploads and commits for the first time;
  • stripped receipts fail loudly naming each missing field (idempotencyKeys, files, packetSha256) before anything reaches the API — in-process and via the CLI exit code.

Verified in this branch's environment:

  • uv run --frozen --extra api pytest tests/test_capture_cold_restart.py tests/test_replay_capture.py tests/test_capture_flow_e2e_runner.py -q — 23 passed;
  • ruff format --check and ruff check — clean;
  • lint-imports — all contracts pass.

Stacked on #262 (obv/burst5-20261015-bs-capture-replay); GitHub retargets it automatically when #262 merges.

Risk / rollout

  • No changes to route contracts, the iOS tree, .github/, or deployment; replay_capture.py's owned files are untouched.
  • New files only: backend/scripts/capture_cold_restart.py, backend/tests/test_capture_cold_restart.py.
  • The tests are hermetic (memory + local volume + stage stubs), so CI needs no secrets or network.

🔗 Obvious Project · 🧵 Obvious Thread · 🔀 Obvious PR Artifact

A device that dies mid-capture must recover from what it persisted and
what the server remembers — not from its own cached state. PR #262's
replay resumed a lost response inside one Python process: the client
that retried was the interpreter that lost the reply.

scripts/capture_cold_restart.py splits the client across two fresh
Python processes against a real create_app() server (memory state,
local volume storage, orchestrator stage stubs — no network, no
credentials):

- the primary client replays a saved capture_flow_e2e receipt and dies
  at a scripted interruption: a lost create/commit/finalize response or
  a stop before commit/finalize, persisting only a client journal of
  what it sent and which replies it saw.
- the resume client reads the receipt and journal and recovers through
  the real routes: create again with the saved idempotency key, then
  register-upload-commit-finalize — or, when the dead process already
  finalized, straight through the finalize's idempotent replay, because
  the capture is closed to files and commits (the server 409s both).

Across the restart it asserts one capture id (a 200 replay, never a
second 201, still mapping to the same capture after the verdict), one
deterministic run id (run_ + sha256 of captureId, NUL and the packet
bytes, unchanged on the second finalize), committed hashes matching the
receipt with no mismatch, and one terminal state (one terminal status,
/result served once for that run, exactly one verdict_ready event).

Every wait is bounded: server readiness, per-leg subprocess timeouts,
per-request timeouts, and capped terminal/event polling. A receipt
written before the replay fields existed fails before anything is sent,
naming the missing field.

tests/test_capture_cold_restart.py drives the CLI as a subprocess for
all five interruption points, asserts the two legs ran as different
fresh processes sharing only the receipt, journal and server, and
checks pre-replay-field receipts fail loudly naming each missing field.

Co-authored-by: Sam Gu <sgu07966@gmail.com>
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 94cb1e6b-7ba5-4828-8e5b-6506e36cc7fb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the size:XL 500-999 effective changed lines. label Oct 15, 2026
@obvious-autobuild

Copy link
Copy Markdown
Author

Independent review (Obvious, Agent fallback — the Autobuild card was declined, so this ran as Agent)

Verdict

ready — still a draft; nothing here blocks ready-for-review. No blocking findings; the PR does what the body claims, and every claim I could execute reproduced.

What the PR is

Two new files, nothing else: backend/scripts/capture_cold_restart.py (+926) and backend/tests/test_capture_cold_restart.py (+234). Purely additive on top of #262's head — the base is obv/burst5-20261015-bs-capture-replay at b5961ac2, head d7becb7c, mergeable: MERGEABLE, so the "GitHub retargets when #262 merges" stacking claim checks out. No route, iOS, .github/, or deployment changes, matching the body's risk section.

What I verified (commands and results)

  • Read the full PR body, every comment, and the complete diff (gh pr view 267 --comments, gh pr diff 267). The only comment is CodeRabbit's review-skip notice; no human reviews yet.
  • Fresh clone, PR head checked out, followed AGENTS.md / CLAUDE.md / CONTRIBUTING.md / backend/CONTRIBUTING.md (uv-based checks; agents never merge — I changed no code).
  • uv run --frozen --extra api pytest tests/test_capture_cold_restart.py tests/test_replay_capture.py tests/test_capture_flow_e2e_runner.py -q on the head → 23 passed in 44.75s — exactly the body's claim. Baseline on b5961ac2 (the two pre-existing suites): 9 passed, so head = 9 pre-existing + 14 new, and the 14-test count in the body matches the file (1 + 3 parametrized + 1 CLI + 5 parametrized + 4 per-stop-point).
  • ruff check and ruff format --check on both files: clean. lint-imports: 5 contracts kept, 0 broken.
  • Full backend suite on the head (uv run --frozen --extra api pytest -q): 1228 passed / 17 skipped / 5 failed in 305s. The 5 failures are the pre-existing baseline already recorded for this repo's base commits (1 SSR-hygiene test_repo_has_no_ssr_shingle_overlap + 4 server/eval/tests/test_gates.py); the PR adds no new failure and only new files.
  • Server contracts the script leans on, checked in source: TOKEN = "d" * 40 matches server/api/tests/conftest.py:15; create idempotency replay (201 → 200, key must equal packetId) at server/api/routes/captures.py:98-100; the volume signed PUT carries its HMAC token in the path ({public}/v1/uploads/{token}, server/common/backends/storage_volume.py:71-77), so put_signed's path-only rewrite is correct for the backend this script pins (STORAGE_BACKEND="volume", :850); events after/wait polling at server/api/routes/events.py:15-23.
  • The two-process claim is real, not asserted-only: the orchestrator spawns the server and both legs as separate sys.executable subprocesses sharing only the receipt, the journal file, and the server; the test compares leg pids and reads each leg's report from disk.

Mutation checks — the new tests bite

Each mutation was applied, tested, then reverted (tree clean at d7becb7c afterwards):

  1. _expected_run_id → fixed string: test_subprocess_recovery_after_client_process_exit[commit-lost] fails (the deterministic-run-id assertion is load-bearing).
  2. Primary leg stops persisting its journal: test_commit_lost_resume_rebuilds_state_from_the_server fails (recovery genuinely depends on the persisted journal, not shared memory).
  3. Resume create sends keys["create"] + "-2" (idempotency key dropped → a second capture would be minted): test_commit_lost_resume_rebuilds_state_from_the_server fails (the one-capture-id invariant is load-bearing).

Findings (ranked; none blocking)

  1. [low] _free_port() bind/close race — capture_cold_restart.py:679-683. The port is bound, closed, then re-bound by uvicorn a moment later; another process can claim it in between and the run dies after a bounded 60s server_not_ready wait instead of failing fast. Smallest fix: retry the listen once or twice on bind failure (or hold the socket and hand it to uvicorn). Dev-tool-only impact: a rare, loudly-reported flake.
  2. [informational] _invariants(receipt, primary, resume) never uses primary — capture_cold_restart.py:653-654. The docstring says "recomputed from both legs' evidence", but every invariant reads only resume and the receipt. Either drop the parameter or genuinely cross-check the dead leg (e.g., primary["journal"]["captureId"] == resume["captureId"] when the journal has one); the test already does the pid/stop cross-checks outside the invariant.
  3. [informational] No stop point between "create response seen" and "commit sent" — STOP_POINTS, capture_cold_restart.py:112-119. A crash mid-upload (some PUTs landed, none committed) is never scripted, though the resume design handles it — re-register asks the server for per-file state and re-PUTs only what is not committed (:501-502). A partial-upload stop point would prove that path; not required for this PR.
  4. [informational] DeviceClient.put_signed drops the query string — capture_cold_restart.py:224-227. Correct today (token in the path for the volume backend this script always constructs), but if a signed URL ever carries its credential in the query this silently breaks. One line of insurance: append ?query when present.

Continuity note (positive): the _finish ok-vs-detail hole flagged in the #262 review (a mid-run exception left errorCodes empty → ok: true, exit 0) is fixed here — both legs append an error code in their except branches (:389, :649) before _finish (:256) computes ok = not errors, so every failure path exits 1 and the report names it. The stripped-receipt tests exercise exactly that path (receipt_missing_field:* before anything is sent).

What I couldn't verify

  • Literal SIGKILL of the primary mid-request: the "lost response" points exit the client after the server processed the request rather than killing a hung socket read. That is a fair model of the failure and the journal/receipt assertions make it hard to fake (mutation 2 shows the tests constrain it), but I did not observe a real process kill.
  • Idempotency across a server restart: the store is memory-backed by construction and the scenario is "client dies, server survives", so cross-server-restart replay is out of scope for this script — consistent with the body's claims, just not something the script can speak to.

I changed no code; this comment is my only write to the PR.

@SamGu-NRX
SamGu-NRX changed the base branch from obv/burst5-20261015-bs-capture-replay to alpha October 16, 2026 04:31
@SamGu-NRX
SamGu-NRX marked this pull request as ready for review October 16, 2026 04:31
@codemetrics-ai

Copy link
Copy Markdown

Note

Review skipped

Codemetrics is installed on this repository, but it hasn't been added to your team yet.

To enable AI code reviews for this repo, go to your dashboard and add BaseScanning to your team's repositories.

@SamGu-NRX
SamGu-NRX merged commit f989516 into alpha Oct 16, 2026
3 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T04:33:26.320092Z d7becb7 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@greptile-apps

greptile-apps Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] New test scripts for client recovery after process restart.

Disable inherited remote Modal execution before merging so this local check cannot start remote work.

Fix All in Claude CodeFindings

  1. P1 Local checks launch remote work ▶
  2. P2 Clients ignore the packet override ▶
  3. P2 Relative paths point elsewhere ▶
  4. P2 Rejected requests count as replay ▶
Fix with agent prompt
### Issue 1
backend/scripts/capture_cold_restart.py:685-690
If the invoking shell has `HSS_ORCH_MODAL_REMOTE=1` and Modal is installed, `_child_env()` passes that switch to the server. `HSS_ORCH_STAGE_STUBS=1` does not block it: the coordinator calls `launch_modal()` before the local stub executor. This can start paid work on the deployed app or fail for missing credentials. Force `HSS_ORCH_MODAL_REMOTE=0` for this local command.

```suggestion
def _child_env() -> dict:
    env = os.environ.copy()
    env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
    # The orchestrator reads these from the environment when the API binds it.
    env["HSS_ORCH_STAGE_STUBS"] = "1"
    env["HSS_ORCH_MODAL_REMOTE"] = "0"
    return env
```

### Issue 2
backend/scripts/capture_cold_restart.py:782-788
`--packet` is checked in the parent but never passed to either client process. If a saved fixture has moved and the user supplies its new location, the parent accepts it, but both clients fall back to the old location or the default fixture and fail. Pass the resolved packet directory to both legs.

### Issue 3
backend/scripts/capture_cold_restart.py:790
Relative `--receipt` and `--workdir` paths change meaning when the clients start with `cwd=ROOT`. Running from the repository root with `--receipt ./saved/receipt.json --workdir ./restart` makes the clients look under `backend/`, while the parent uses the repository root. Valid paths then produce missing-receipt or missing-report errors. Resolve these paths before starting any child process.

### Issue 4
backend/scripts/capture_cold_restart.py:379-382
The `commit-lost` and `finalize-lost` branches return success before checking whether the server accepted the request. A rejected request can therefore be followed by the resume client's first successful operation, while the report claims it proved replay after a lost successful response. Check the primary request's status and commit results before discarding its reply, without copying that reply into the device journal.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a local capture-recovery command with separate primary and resume Python processes.

  • A fresh client process can resume a capture after a restart.

Diagram

sequenceDiagram
    participant Parent as Command
    participant Primary as Primary process
    participant Server as Local API
    participant Resume as Resume process
    Parent->>Server: Start server
    Parent->>Primary: Start with receipt
    Primary->>Server: Replay until selected stop
    Primary-->>Parent: Save journal and exit
    Parent->>Resume: Start fresh with receipt and journal
    Resume->>Server: Recover capture and finalize
    Resume->>Server: Read status, result, and events
    Resume-->>Parent: Save checks
    Parent->>Parent: Write combined report
Loading

Reviews (1) · Last reviewed commit: "feat(capture): replay a capture after a ..." · Reviewed by Greptile

Comment on lines +782 to +788
"--receipt",
str(receipt_path),
"--workdir",
str(workdir),
"--server-url",
server_url,
*extra,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Clients ignore the packet override

--packet is checked in the parent but never passed to either client process. If a saved fixture has moved and the user supplies its new location, the parent accepts it, but both clients fall back to the old location or the default fixture and fail. Pass the resolved packet directory to both legs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/scripts/capture_cold_restart.py
Line: 782-788

Comment:
**Clients ignore the packet override**

`--packet` is checked in the parent but never passed to either client process. If a saved fixture has moved and the user supplies its new location, the parent accepts it, but both clients fall back to the old location or the default fixture and fail. Pass the resolved packet directory to both legs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

server_url,
*extra,
],
cwd=str(ROOT),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Relative paths point elsewhere

Relative --receipt and --workdir paths change meaning when the clients start with cwd=ROOT. Running from the repository root with --receipt ./saved/receipt.json --workdir ./restart makes the clients look under backend/, while the parent uses the repository root. Valid paths then produce missing-receipt or missing-report errors. Resolve these paths before starting any child process.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/scripts/capture_cold_restart.py
Line: 790

Comment:
**Relative paths point elsewhere**

Relative `--receipt` and `--workdir` paths change meaning when the clients start with `cwd=ROOT`. Running from the repository root with `--receipt ./saved/receipt.json --workdir ./restart` makes the clients look under `backend/`, while the parent uses the repository root. Valid paths then produce missing-receipt or missing-report errors. Resolve these paths before starting any child process.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +685 to +690
def _child_env() -> dict:
env = os.environ.copy()
env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
# The orchestrator reads this from the environment when the API binds it.
env["HSS_ORCH_STAGE_STUBS"] = "1"
return env

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Local checks launch remote work

If the invoking shell has HSS_ORCH_MODAL_REMOTE=1 and Modal is installed, _child_env() passes that switch to the server. HSS_ORCH_STAGE_STUBS=1 does not block it: the coordinator calls launch_modal() before the local stub executor. This can start paid work on the deployed app or fail for missing credentials. Force HSS_ORCH_MODAL_REMOTE=0 for this local command.

Suggested change
def _child_env() -> dict:
env = os.environ.copy()
env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
# The orchestrator reads this from the environment when the API binds it.
env["HSS_ORCH_STAGE_STUBS"] = "1"
return env
def _child_env() -> dict:
env = os.environ.copy()
env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
# The orchestrator reads these from the environment when the API binds it.
env["HSS_ORCH_STAGE_STUBS"] = "1"
env["HSS_ORCH_MODAL_REMOTE"] = "0"
return env
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/scripts/capture_cold_restart.py
Line: 685-690

Comment:
**Local checks launch remote work**

If the invoking shell has `HSS_ORCH_MODAL_REMOTE=1` and Modal is installed, `_child_env()` passes that switch to the server. `HSS_ORCH_STAGE_STUBS=1` does not block it: the coordinator calls `launch_modal()` before the local stub executor. This can start paid work on the deployed app or fail for missing credentials. Force `HSS_ORCH_MODAL_REMOTE=0` for this local command.

```suggestion
def _child_env() -> dict:
    env = os.environ.copy()
    env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
    # The orchestrator reads these from the environment when the API binds it.
    env["HSS_ORCH_STAGE_STUBS"] = "1"
    env["HSS_ORCH_MODAL_REMOTE"] = "0"
    return env
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +379 to +382
journal["finalizeExecuted"] = True
if stop == "finalize-lost":
record("finalize", responseLost=True)
return _finish(report, errors, None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Rejected requests count as replay

The commit-lost and finalize-lost branches return success before checking whether the server accepted the request. A rejected request can therefore be followed by the resume client's first successful operation, while the report claims it proved replay after a lost successful response. Check the primary request's status and commit results before discarding its reply, without copying that reply into the device journal.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/scripts/capture_cold_restart.py
Line: 379-382

Comment:
**Rejected requests count as replay**

The `commit-lost` and `finalize-lost` branches return success before checking whether the server accepted the request. A rejected request can therefore be followed by the resume client's first successful operation, while the report claims it proved replay after a lost successful response. Check the primary request's status and commit results before discarding its reply, without copying that reply into the device journal.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 effective changed lines.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants