Files
paperclip/doc/plans/2026-11-06-control-plane-restart-chaos.md
T
DottaandClaude Opus 5.5 871a9b3180 test(restart-chaos): add Phase 1 control-plane restart chaos harness (#19477)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The control plane owns task execution, recovery and Retry for every
agent run
> - Server restarts were the largest remaining source of stalled tasks
in the staging soak campaign
> - The soak could only observe restarts that staging happened to
perform, so a failure could not be repeated
> - This pull request adds a script harness that causes the restarts on
purpose and grades each task on fixed rules
> - The benefit is a repeatable restart failure with a replay command
and an evidence bundle for each fixer

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The restart test coverage. Today the runner-e2e restart cells test one
fixed restart point per cell. They cannot run many tasks at once, cannot
choose a random fault time, and cannot replay a failure.

**Subsystem affected**
Test tooling (`tests/restart-chaos/`). It reuses the runner-e2e process
code in `tests/runner-e2e/`. Product code does not change.

**Current behavior**
Restart robustness is checked through a small set of fixed cells. A
random restart during a real task has no defined pass rule.

**Proposed behavior**
A harness starts an isolated control plane and seeds a "Chaos Lab"
company. It launches seeded workloads, injects K1, K2 or K4 faults at
seeded times, and grades each task on rules R1 to R6 plus a
deterministic task oracle. `--replay <wave>/<task>` repeats one task.

**Reason and benefit**
Fixers get a replay command and an evidence bundle for each failure
signature. The first smoke wave already found three product signatures
(dead Retry after a restart, a false over-budget stop after restarts,
and an OpenCode turn killed mid-turn that ends as a provider failure).

**Breaking changes**
None. The change adds new test files, one entry in the test-project
typecheck list and a gitignore entry.

## What Changed

- Add `tests/restart-chaos/`:
- `supervisor.ts` owns one isolated server. It reuses
`replaceController`, the process-tree stopper, the environment isolation
helpers, the config seed, the port reservation, the wrapper-owned
Postgres and `server-entry.ts` from `tests/runner-e2e/`. It does not
copy them. Faults: K1 (SIGKILL, immediate restart), K2 (SIGTERM,
restart) and K4 (SIGKILL, 2 to 5 minute hold). Postgres and the
controller runtime cache stay across a fault.
- `seed.ts` creates the company, encrypted provider secrets, native
Codex, ACPX Claude and OpenCode agents from the runner-e2e catalog, and
the `chaos-sandbox` project. The project is a git repository with a
Python CLI, one failing test and a four-minute `bench.sh`.
- `scheduler.ts` makes a seeded plan of workload, adapter, fault, fire
time, hold and nonce for each task. It saves the plan so that `--replay`
can run one task again.
- `observer.ts` polls each issue and its runs. When a task stops and
offers Retry, it sends `POST /api/issues/:id/retry` one time, with the
same body as `useTaskRetry.ts`.
- `grader.ts` grades R1 to R6 as pure functions. `workloads.ts` defines
workloads A1, B1, B5 and C1 and their oracles.
- `evidence.ts` and `ledger.ts` write an evidence bundle for each
failure, a JSONL ledger and `SUMMARY.md`. All results go to the
gitignored `tests/restart-chaos/results/`. Known provider key values are
redacted.
- Unit tests: `scheduler.test.ts` (determinism, replay and CLI parsing)
and `grader.test.ts` (R1 to R6 and the oracles on fixture timelines).
These tests need no credentials.
- Add `tests/restart-chaos` to the test-project typecheck list
(`scripts/typecheck-test-projects.mjs`), with a `tsconfig.layers.json`
like `tests/runner-e2e`. The harness runs through direct script
invocations, not `package.json` scripts.
- After the first Greptile review:
- A failed fault or an unexpected server exit now gets one bounded
recovery. If the recovery fails, the wave fails and does not hang.
- Incomplete cleanup is now a harness error, and the harness keeps the
instance.
- After the C1 follow-up comment, the observer needs a fresh rest window
before it grades the task.
- `--replay` now fires the recorded faults that crossed the task, at the
same offsets, including the faults that other tasks requested.
- A failed workspace-operation read is now a sweep error. It cannot give
a clean rest.
- R2 now checks that workspace files from before the first fault still
exist at rest.
- B5 now needs the completion record that `bench.sh` writes for each
task.
- After the second round of reviews:
- An idle `todo` or `in_progress` task with no live run and no named
wait is no longer treated as resting. The observer keeps watching it
until the task resumes or passes the R5 stall limit.
  - A failed injected fault now fails the wave with exit 2.
- T-oracle commands run agent-editable code. They now run under a macOS
Seatbelt sandbox with a scratch `HOME` and no network, and they cannot
read the operator's home directory. On other platforms these commands
are refused unless the operator explicitly allows them.
- R4 now counts every active run. A wake comment may be served again
only after a run that did not succeed.
- Evidence redaction now covers every environment variable name that
ends in `KEY`, `TOKEN`, `SECRET` or `PASSWORD`.
- If the pre-fault snapshot fails, only R2 for that task is left
unchecked. The wave continues.
- B5 also needs bench progress that the harness observed while it
polled.
- Add `doc/plans/2026-11-06-control-plane-restart-chaos.md` and its
campaign ledger.

## Verification

- `pnpm exec vitest run --config tests/restart-chaos/vitest.config.ts`:
38 tests pass. The tests include observer tests with a fake API, a real
`bench.sh` run, and a sandbox test that cannot read a canary secret.
- `node scripts/typecheck-test-projects.mjs tests/restart-chaos`:
passes.
- `server/scripts/typecheck-layers.test.mjs`: 45 tests pass, including
"covers every project under tests/".
- A supervisor check with no credentials passed. K1 was ready again in
about 6 seconds. K2 with a 3-second hold was ready again in about 9
seconds. Company data stayed readable.
- One paid smoke wave ran: `--env local --adapters codex,claude,opencode
--workloads A1,B1,C1 --faults K1 --tasks 3 --concurrency 2 --seed 7`.
The harness worked from start to end, and all three tasks crossed a
restart. The 0/3 result comes from product failures, which the campaign
ledger records. A scan found no provider key in the results.
- To run a wave:

  ```sh
  pnpm --filter @paperclipai/paperclip-runner build:runner-binaries
  set -a; source ~/.secrets; set +a
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts --env
local --adapters codex,claude,opencode --concurrency 4 --tasks 8
--faults K1,K2,K4 --seed 42
node cli/node_modules/tsx/dist/cli.mjs tests/restart-chaos/main.ts
--replay <wave>/<task>
  ```

## Risks

- Low product risk. The change adds test tooling only and does not
change product code.
- A wave spends provider money. The company budget hard cap
(`--budget-cents`, default 3000) is the only spend limit. The plan's
per-wave and per-night refusal is not implemented yet.
- Known gaps:
- `--env daytona` is reserved. It fails with a TODO in `seed.ts`. The
Daytona sandbox sweep for R6 is not implemented yet.
- The project requests isolated git-worktree workspaces. A B1 task still
ran in the shared fixture repository. Concurrent B1 and B5 tasks can
share files. The oracles use marker files for each task.
- Only K1, K2 and K4 at seeded random times are available. Boundary
triggers (B1 to B16), other faults and the UI probe come in Phase 2.
  - The paid smoke did not exercise B5 or K4.
- On macOS, B1 oracle commands need `python3` outside the operator's
home directory, for example `/usr/bin` or Homebrew. On Linux they are
refused until a sandbox is added.
- The B5 proof is best-effort against a deliberately adversarial agent,
because that agent can write to the workspace.
- Startup phase timings come from polling `/api/ready`. The server
records its own timings only as OpenTelemetry spans.
- `pnpm verify` and the CI Vitest jobs do not select these unit tests.
The root Vitest projects cover workspace packages only. CI typechecks
the files, but the tests run only through the vitest command above.
- After the smoke, rest detection and the review fixes changed. A
recovery action that the board owns now counts as a rest, and R6 reports
it. A paid wave did not run again after these changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (Anthropic), model ID `claude-opus-5-5`, used in
Claude Code as an agent with tool use (shell, file edits) and extended
thinking.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-06 10:45:52 -06:00

18 KiB
Raw Blame History

Control-plane restart chaos loop: make tasks survive server restarts

Date: 2026-11-06 Target: a local, isolated Paperclip control plane with agent runs in Daytona sandboxes Fix workflow: fix-failed-paperclip-run (one session per failure signature) Predecessor: 2026-11-04-staging-soak-loop.md (staging soak campaign, PR worktree paperclip-staging-test-plan-24ca37)

1. Why this plan exists

The staging soak campaign (2026-11-04 to 2026-11-06) ran about 430 graded tasks in waves of about 10. It used a fixed task library (sections A to H), mechanical oracles (U1 to U9), and a dedupe-by-signature ledger. Each new failure signature went to its own fix-failed-paperclip-run session. The campaign merged 42 fixes, and the hard-failure rate on test tasks fell from 43% to about 0%.

The largest remaining source of failures was restarts. Staging restarted up to about five times an hour. The campaign summary says "frequent restarts stall in-flight work". Three fixes in this area merged late in the campaign:

  • #19399: lost runner after a restart continues automatically, and Retry is offered.
  • #19426: a declined Retry no longer swallows later clicks (the "dead Retry" bug).
  • #19431: queued runs start promptly after a restart, and the wait shows a reason.

The campaign could only observe restarts that staging happened to perform. We could not choose when the restart came, what kind it was, or what each run was doing at that moment. This plan reverses that: we cause the restarts on purpose, against a local control plane whose runs execute in Daytona. We run many tasks at once and repeat until nothing breaks.

2. Success rule (the oracle)

A run is restart-crossing if any part of it overlaps a control-plane fault: its queued wake, preparation, provider turn, finalization, or a follow-up. Every restart-crossing task must meet all of the rules below.

ID Rule Failure means
R1 Automatic continuation. If continuing is safe, the task resumes after the server is back, with no user action, and reaches its expected resting state within the task's time budget plus the outage time. Not robust to restart
R2 Durable workspace. No workspace, durability, or recovery error stays visible at rest. This includes restore, snapshot, finalize, writer-pending, lease mismatch, and stranded lock errors. If such an error appears during recovery, it clears by itself. Files the agent wrote before the fault are present afterwards, and nothing is silently rolled back. Durability bug
R3 Working Retry. If the task cannot safely continue on its own, it shows exactly one Retry or continue affordance. One click (POST /api/issues/:id/retry, and the button in the UI) starts a run that completes the task. "Send a message to continue", a Retry that does nothing, a missing Retry, or a Retry that needs several clicks all fail this rule. Dead or missing Retry
R4 No duplicate execution. There is at most one active run per issue, no side effect is repeated, and no user message is consumed twice. This is soak oracle U4 and U5. Replay or duplicate run
R5 No silent stall. A task is never in_progress or queued with no live run, no queued wake, and no named wait reason for more than 3 minutes after /api/liveness reports ready. Stall
R6 Clean rest. At the end of the wave there is no pending workspace operation, recovery action, orphaned lease, or orphaned Daytona sandbox. Leak

The paid "kill at an arbitrary point" probes (recover-runner*, see tests/runner-recovery/README.md) were retired because they demanded automatic completion after any kill. For a turn whose outcome is unknown, automatic completion is not always safe. R1 and R3 resolve this: when automatic continuation is not provably safe, the correct result is a Retry that works on the first click. A random kill therefore always has a defined pass condition. It is never graded "environment, ignore".

3. Topology

 chaos harness (Node script, no LLM)
   ├─ supervisor: owns the server process (start, SIGKILL, SIGTERM, hot restart, outage hold)
   ├─ launcher: creates tasks through the board API
   ├─ observer: polls issues, runs and run events; grades R1–R6; captures evidence
   └─ ledger: one JSONL line per task execution, plus the fault schedule seed

 local control plane (isolated PAPERCLIP_HOME, embedded Postgres, one fixed port)
   └─ dials out to runnerd in each Daytona sandbox (runner ingress, port 43127)

 Daytona: one sandbox per run or lease, holding the real runner and provider (Claude, Codex)
  • Use an isolated instance, not ~/.paperclip/instances/default. Chaos kills and the leftover Postgres state must never touch Dotta's working data.
  • Own the server process. Reuse the existing supervisor in tests/runner-e2e/server.ts and controller-outage.ts (replaceController: stop → wait for unhealthy → hold → start → ready, with a receipt). It already handles process groups, SIGKILL escalation, and embedded-Postgres ownership. Do not use pnpm dev (watch mode): when its child dies, the dev runner exits too.
  • Readiness. Use /api/liveness and the startup phases (starting → recovering → ready) to time the outage and the recovery window. startup-recovery-timing.ts logs each phase.
  • Keep across restarts: the Postgres data and the instance secret key. A restart that loses either one is a different fault (K11) and is graded on its own.
  • Concurrency. Start with 6 concurrent tasks and raise to 12, then 20. The limits are the Daytona sandbox quota and provider spend. Settle the quota in Phase 0.

4. Fault menu

4a. What the fault does (K)

ID Fault How
K1 Hard kill, fast return SIGKILL the server, restart immediately. Hot-restart reconciliation reports not_requested.
K2 Graceful stop SIGTERM (shutdown drain and finalizers run), then restart.
K3 Hot restart scripts/request-hot-restart.ts, then SIGTERM. Runs should be adopted.
K4 Medium outage Hard kill and hold for 2–5 minutes. This exceeds the 60-second controller lease but stays within the 7-minute runner reconnect grace.
K5 Long outage Hard kill and hold for 8–20 minutes. This exceeds the reconnect grace, and past 15 minutes Daytona auto-stops idle sandboxes. The runner gives up, so recovery must go through saved work.
K6 Crash loop Kill again during recovering, then a third time just after ready.
K7 Kill during the automatic retry Kill again while the automatic retry or continuation started by R1 is running.
K8 Kill during a user's Retry Kill between the Retry click (receipt committed) and the start of the new run.
K9 Postgres dies too SIGKILL embedded Postgres along with the server.
K10 Network partition, no restart Block the server's outbound traffic to Daytona for 30 seconds to 5 minutes.
K11 Lost local state Restart with the controller cache and scoped state directory deleted, as on a new container (remote_reattach_root_missing). The database is kept.
K12 Rolling deploy Start a second server on the same database before stopping the first, as in rolling-deployment-supervisor.ts.
K13 Restart storm Restart every 2–4 minutes for an hour with 20 tasks running.

4b. When the fault hits (B, run boundary)

Some faults fire at a random time: a seeded random generator chooses the time, so a failure can be replayed. Others are boundary-triggered: the observer watches run events and status and fires the fault at a chosen transition. Each boundary below lists the run event or state it fires on.

ID Boundary Fires on
B1 Queued, not yet dispatched Run queued
B2 Sandbox acquire or provision Lease acquire started, sandbox not ready
B3 Workspace restore or prepare Restore or prepare operation in progress
B4 Runner bootstrap or session open Native bootstrap events
B5 Mid model turn, streaming First assistant output event
B6 Mid long tool call Tool-start event without a tool-end, for example bench.sh running about 4 minutes
B7 Waiting on a question or approval Pending interaction
B8 User message or steering queued during the run Queued comment exists
B9 Turn done, finalization or sync-out in progress workspace_finalize or sync operation running
B10 Snapshot or archive upload Snapshot upload begun
B11 Done, before publication Durable done, reply not yet published (as in the chat-restart cell)
B12 Scheduled-retry backoff Run scheduled_retry
B13 Stop or interrupt settling Cancel requested, not settled
B14 Parent waiting on delegated children Parent blocked on children, children running
B15 Routine fire or timer wake Routine run being created
B16 Reassignment in flight Assignee changed, old run not ended

The finalization-crash-boundary.ts and controller-recovery-observer.ts modules in tests/runner-e2e/ already bind kills to some of these boundaries. Reuse them; do not write them again.

4c. Workload (W)

Use a smaller subset of the soak library, chosen so the boundaries above occur naturally:

  • A1: brief written to an issue document. A short run with no repository.
  • A3: CSV analysis whose answer can be checked exactly.
  • B1: Python CLI with tests. Workspace files must survive the fault. The harness runs the tests itself.
  • B2: fix a failing test, plus a revision follow-up. This exercises workspace reuse across the fault.
  • B5: a long bench.sh run, which reaches boundary B6.
  • C1: a comment sent while the run is active (B8).
  • C2: Stop, then a new direction (B13).
  • A5: the agent asks a clarifying question (B7).
  • D1: CEO delegates to children (B14).
  • E1 or H10: a routine (B15).
  • C6: reassignment (B16).

Each task has a deterministic T-oracle, so a restart cannot hide wrong output.

4d. Adapter (A)

  • Primary: native Claude and native Codex on Daytona.
  • Control: the same tasks with a local runner. This tells apart failures that are Daytona-specific from failures in control-plane recovery.
  • Later: other adapters, once the primary pair is green.

The full cross product is about 13 × 16 × 11 × 2. That is too large to run exhaustively. Waves therefore sample pairwise coverage: every fault paired with every boundary, and every boundary on every adapter, at least N times. The remaining slots go to open signatures and random K1 and K13 storms.

5. Harness (tests/restart-chaos/)

The harness is a script, not an LLM, for the same reasons as the soak harness: deterministic grading and cheap polling. It reuses the runner-e2e process code. It does not copy that code.

  1. config: isolated instance path, port, DAYTONA_API_KEY, company, agents, budget, and concurrency.
  2. seed: creates the "Chaos Lab" company, Claude and Codex agents on Daytona, and the chaos-sandbox project with a fixture repository. This includes bench.sh and one failing test. It sets hard budgets.
  3. supervisor: wraps server.ts and controller-outage.ts, exposing kill(kind), hold(ms), start(), waitReady(), partition(ms) (for example a pf or iptables rule, or a proxy in front of runner ingress), and wipeLocalState(). Every action is written to the timeline.
  4. scheduler: a seeded plan of faults and boundaries for each wave. The seed and the chosen cells are saved, so --replay <wave>/<task> repeats the same fault at the same boundary.
  5. observer: polls every 2–5 seconds and evaluates R1–R6 and the T-oracles. When a task ends in the R3 state, the observer acts as a user would: it calls Retry once through the API, the same call the UI hook makes in useTaskRetry.ts, and grades the result.
  6. ui-probe: every few waves, a Playwright pass opens one task that is in the R3 state, checks that the Retry button is visible, clicks it once, and confirms the task continues. The dead-Retry bug (#19426) was in the UI path, so checking the API alone is not enough.
  7. evidence bundle (captured at failure time): run IDs and statuses, the last 200 run events for each run, the server log around the fault, the startup-phase timings, the recovery claim dispositions (claimNativeRestartRecoveries), workspace operations, leases and their Daytona sandbox state, the fault timeline, the seed, and the SHA.
  8. ledger and summary: the soak ledger format, plus the fault, boundary and adapter fields. A small dashboard artifact shows pass rate per cell and the open signatures.
  9. sweep: at the end of each wave, reconcile Daytona sandboxes against leases (R6). Then destroy whatever the harness created.

6. Triage and the fix loop

wave (N tasks × sampled K/B/A cells)
  → grade R1–R6 and the T-oracles
  → classify failures; signature = component + first causal error + boundary
  → new P signature  → fixer session running /fix-failed-paperclip-run with the bundle and a replay command
  → known signature  → add the bundle to the existing record
  → fix PR merged    → rebase the harness worktree on master, replay that signature 5× plus its neighboring cells
  • Classes: the same as the soak campaign. P is a product bug, Q is agent quality, T is a test or harness defect. E (environment) never applies to the fault we injected. Only an unrelated provider or Daytona outage counts as E.
  • Fixers start from the replay. Each fixer gets --replay, so the first step of fix-failed-paperclip-run (establish the incident) is already done. The fixer then writes the smallest red test at the right layer. Usually that is a server integration test on embedded Postgres, like the six in #19399, or a native-runner-restart-*.integration.test.ts. The fix makes it green.
  • Merge authority: the same as the soak campaign. Fixer sessions merge their own PR at Greptile 5/5 with all required checks green, and run check-pr.
  • Concurrency: at most 4 fixer sessions at once.
  • No deploy lag. Fixes are retested locally as soon as they merge, which is the main speed advantage over the staging loop. Staging stays the final confirmation (Phase 4).

7. Phases

Phase Output Exit
0. Baseline Isolated instance, Daytona key and quota confirmed, Chaos Lab seeded. Run the existing everyday-workflows.runner-codex.daytona.recover-controller cell and controller-replacement-smoke.ts as they are today. Also run 10 tasks by hand with a K1 kill each, to measure the starting failure rate. The baseline failure rate is recorded and the first signatures are filed.
1. Harness MVP Supervisor, launcher, observer with R1–R6, ledger and replay. Faults K1, K2 and K4 at random times. Workloads A1, B1, B5 and C1 on Claude and Codex. 6 concurrent tasks. Two waves run without supervision, and the evidence bundles are good enough for a fixer to start from.
2. Boundary matrix Boundary triggers B1–B16 and the full K list except K12 and K13. Pairwise sampling. UI probe. Every K×B pair has been run at least 3 times.
3. Stress Concurrency 12, then 20. K13 storms (an hour of restarts every 2–4 minutes). K12 rolling deploys. Overnight runs. Finish line (§8).
4. Lock in Each fixed signature has a red/green regression test in its PR. Stable cells are promoted to explicit runner-e2e cells (recover-controller style) for release qualification. Then one confirmation wave on staging. Recurring regressions are caught in CI, not by chaos.

8. Finish line

At one pinned master SHA:

  1. 500 consecutive restart-crossing task executions across the sampled matrix with zero R1–R6 failures. At least 20 of them cross K5 (the long outage) and at least 20 cross K6 or K7 (repeat kills).
  2. Every K×B cell has at least 5 passes on both Claude and Codex.
  3. Two K13 storm hours at concurrency 20 in which every task reaches its resting state, and the R3 Retry works on the first click every time.
  4. No open P signature remains in the ledger.
  5. One staging wave after deploy confirms the result. There, U9 sees no restart failure across all restarts in a 24-hour window.

9. Cost and safety

  • Hard budget per wave (start at $30) and per night (start at $150). The harness refuses to start a wave above 80% of the cap.
  • The harness signals only processes it started, using the PID and start-time checks in process-tree-owner.ts. It never touches the default instance or any other dev server.
  • The Daytona cleanup sweep runs after every wave and on harness exit. Orphaned sandboxes are counted as R6 failures before they are deleted.
  • No real credentials are placed in fixtures. The canary secret check (soak U7) stays on, because recovery paths replay logs.

10. Decisions needed from Dotta

  1. Daytona quota and concurrency ceiling: is 20 concurrent sandboxes OK, or is the cap lower?
  2. Budget: are $30 per wave and $150 per night acceptable caps?
  3. K5 bar (outage longer than the 7-minute reconnect grace): is a working Retry an acceptable outcome, or must the task continue automatically from saved work? The plan assumes automatic continuation is required when the last provider turn settled, and Retry is acceptable when the turn was in flight. This matches the #19399 rule.
  4. Adapters for the finish line: Claude and Codex native only, or also the ACPX adapters?
  5. K12 rolling deploys: include them now, or wait until zero-downtime web deploys (2026-10-31-zero-downtime-web-deploys.md) is the production path?