The 60h stability soak (Friday 19:30 Pacific → Monday 07:30 Pacific) is the
60-hour merge-recovery soak. It produces week-over-week benchmark metrics
instead of pass/fail only. The Mon–Thu 22h dev integration soaks run the same
suite against dev but are not benchmark runs — see
60h stability vs dev integration.
Process names map to legacy machine identifiers, which stay unchanged until a separate identifier migration:
| Process name | Legacy series key | Legacy profile value |
|---|---|---|
| 60h stability soak | weekend |
weekend-60h |
| Dev integration soak | daily |
— |
Design history and decisions: work log, story US-004 in UserStories.md.
A soak is a repeated lifecycle test, not continuous monitoring of one standing shard. The terms used by the workflow, artifacts, badges, and dashboard have distinct scopes:
| Term | Meaning |
|---|---|
| Run | One scheduled or manually dispatched soak against one pinned node commit. Dev integration runs target dev. 60h stability runs target master. The node image is built once and reused throughout the run. |
| Segment | A wall-clock slice of the same run, used to publish checkpoints. Segments share state, output, counters, and iteration numbering. A new segment does not create a new run. |
| Iteration | One invocation of the integration load test using a newly created six-node shard. It covers shard creation, health readiness, every load phase, finalization and convergence checks, telemetry collection, and teardown. |
| Phase | One load level inside an iteration. All five phases run in order against the same shard. |
flowchart TD
A["Start or resume soak segment"] --> B{"Run budget remains?"}
B -- "No" --> Z["Roll up results<br/>publish checkpoint or final report"]
B -- "Yes" --> C["Increment iteration<br/>alternate Docker / subprocess provider"]
C --> D["Create iteration directory<br/>start pytest and resource monitors"]
D --> E["Create fresh six-node shard"]
E --> F["Wait for initial LFB advancement"]
F --> G["Next phase<br/>low → medium → high → burst → sustained"]
G --> H["Submit lightweight deploys<br/>across three validators"]
H --> I["Capture tip−LFB cone depth"]
I --> J["Wait for phase deploys to finalize"]
J --> K["Record latency, throughput,<br/>LFB and node-internal metrics"]
K --> L{"More phases?"}
L -- "Yes" --> G
L -- "No" --> M["Snapshot all-node LFBs<br/>emit lfb_spread metric"]
M --> N{"Hard gates pass?"}
N -- "Yes" --> O["Verify every node is still running"]
N -- "No" --> P["Destroy shard in finally block"]
O --> P
P --> Q["Write per-iteration metrics.json"]
Q --> R{"pytest exited successfully?"}
R -- "Yes" --> S["Count successful iteration<br/>add duration to shard_up_seconds"]
R -- "No" --> T["Count failure<br/>preserve logs and monitor evidence"]
S --> U["Check signal, deadline,<br/>target movement and host guard"]
T --> U
U --> V{"Stability-soak benchmark due?"}
V -- "No" --> B
V -- "Yes" --> W["Run controlled-rate benchmark<br/>on a separate fresh local shard"]
W --> B
Each iteration alternates providers, beginning with Docker and then subprocess. Both providers exercise the same test contract. This sequence detects provider-specific startup, networking, cleanup, and telemetry failures. The fresh shard contains:
- one bootstrap node
- four bonded genesis validators
- one readonly node
The first three validators receive deploy submissions. The fourth validator participates in consensus so concurrent proposals create realistic sibling blocks and multi-parent merges. Before load begins, the test requires an initial last-finalized-block (LFB) advancement to prove that the shard is making progress.
The iteration then runs this fixed load sequence. Rated phases submit
lightweight @N!(N) contracts. These contracts stress deploy admission,
proposal, block processing, and finalization rather than Rholang execution.
| Phase | Load | Duration | Workers | Planned deploys |
|---|---|---|---|---|
| low | 1 deploy/s | 30s | 1 | 30 |
| medium | 5 deploys/s | 20s | 3 | 100 |
| high | 10 deploys/s | 15s | 3 | 150 |
| burst | immediate burst | — | 3 | 32 |
| sustained | 4 deploys/s | 300s | 3 | 1,200 |
| Total | 1,512 |
After every phase, the harness waits for tracked deploys to finalize. The
harness records latency percentiles, submission rate, LFB advance, cone depth,
and per-validator node metrics. After the sustained phase drains, the harness
records each node's LFB and emits
SOAK_METRIC name=lfb_spread value=<max-LFB-minus-min-LFB> phase=drain.
An iteration passes only when the complete contract succeeds:
- The fresh shard starts and advances its LFB.
- No deploy submission fails.
- Every submitted deploy finalizes within the configured timeout.
- All six nodes reach an LFB spread of five blocks or less within the budget.
- Every node is still active after the load.
The harness destroys the shard after a pass or a failure. The driver then
writes iteration-NNNNN-<provider>/metrics.json. This file contains timestamps,
the provider, pytest counts, the exit code, resource peaks, latency, proposal
errors, and registry metrics.
For a failed iteration, the driver keeps diagnostic logs and monitor files. The driver waits for 30 seconds and usually starts a new shard. A host protection breach ends the run instead.
The driver reads checkpoint and finalize signals only between iterations. Thus,
these signals do not interrupt an active lifecycle. A segment deadline can stop
an active pytest process. The driver records deadline.txt and exit code 124
instead of a normal test failure.
On 60h stability runs only, every fourth iteration is followed by a separate active
benchmark segment. This benchmark creates another fresh local shard and applies
a fixed-rate workload. It records latency, throughput, finalization, and RSS
measurements before it removes the shard. The benchmark is separate from the
preceding iteration and shard_up_seconds. Its results populate the run's
active metrics.
shard_up_seconds includes only the duration of iterations that passed the
complete lifecycle. A failed iteration can run all six nodes and produce much
telemetry. However, it adds zero because the shard did not satisfy the complete
health contract. Thus, the dashboard stability value is an iteration success
rate. It is not the availability of a long-lived deployment.
The node-side orchestrator is
scripts/run-merge-recovery-soak.sh.
The test contract and phase definitions live in
test_load.py
in the system-integration repository.
-
Trend dashboard (charts, per-provider split, run links): 60h stability and Dev integration — each tab shows its own verdict, and every chart title and image is directly linkable.
-
Weekly email alert: plain-text summary with the verdict and dashboard links, sent via OCI Notifications when the soak concludes.
-
Per-run detail: the
merge-recovery-soak-*artifact on the workflow run (iteration metrics, benchmark segments, logs,report/withweekly-summary.json,verdict.json,badge.json,perf-report.md). -
README badges: four shields.io endpoint badges, all generated from the same
verdict.json/weekly-summary.jsonthe dashboard renders, so a badge cannot disagree with the page behind it:Badge Endpoint file Producer soak · masterdata/badge-soak.jsonbadge.jsonsoak · devdata/badge-soak-daily.jsonbadge.jsonstabilitydata/badge-stability.jsonbadge-stability.jsonperfdata/badge-perf.jsonbadge-perf.jsonstabilityis the share of iterations that completed a full bring-up → load → finalize cycle — a success rate, deliberately not called uptime, since the soak creates a fresh shard per iteration rather than watching a standing deployment. Its colour bands are absolute and advisory; the release gate is relative (week-over-week) and stays with the soak verdict, so 100% stability alongside aregressverdict is coherent.perfis always blue: a readout, not a judgement, because absolute latency and throughput have no threshold here.There are no CI badges. Per-commit build status is already rendered on the repository home page and in pull requests; the badge row is reserved for the soak signal, which has no other surface.
Both series publish to Pages, into separate files — history.json and
history-daily.json, each with its own latest-summary, latest-verdict,
latest-report and badge-soak. They are kept apart so the week-over-week
regression gate never compares a variable-length dev integration run against
the fixed 60h stability baseline. A Pages deploy replaces the whole site, so whichever soak
publishes carries the other series forward untouched; a transient fetch failure
aborts the deploy rather than publishing a site with a series missing.
Three workflows publish the site — merge-recovery-soak.yml (final),
soak-checkpoint-publish.yml (mid-run) and soak-dashboard-pages.yml
(dashboard edits) — and each carries the published data forward from its own
file list. A new data file must be added to all three, or the next publisher
to run deletes it. Chart SVGs are the one exception: they ride
charts-manifest-<series>.json, written by the renderer to name exactly the
SVGs it produced, and the publishers iterate that manifest — so adding a chart
is a renderer change only.
Every chart on the dashboard is a pre-rendered SVG pair
(…-<series>-{light,dark}.svg) from the standalone scripts/soak-charts crate
(charton; deliberately not a workspace member — CI runs a dedicated cargo-deny
pass for it, and its committed Cargo.lock pins the publisher builds via
--locked). Colors are baked at render time, so each chart ships a light and a
dark variant and the page swaps them with its theme logic.
The failure map is a heatmap: rows are failure categories (total, per provider), columns are run dates, cell color is the failure rate on a sequential red ramp with a neutral non-red for 0%, and cell text carries the failures/iterations volume. The metric panels (throughput, peak RSS/CPU, finalization latency, too-far-ahead, LFB spread) pick their mark from data density at render time: with enough distinct dates they are layered line+point charts on a temporal axis (throughput adds a low-opacity trend area); with only a few they render as value-labelled bars or points, because a two-point line chart is mostly empty axis. A panel with no recorded data emits nothing, and the too-far-ahead counter is suppressed while it is all-zero — the page shows a "0 · target 0" badge instead of a flat line. LFB spread is the exception to no-data suppression: its stable eighth slot remains visible with an awaiting-data message until the harness emits its first sample, preventing a missing upstream contract from masquerading as a seven-chart layout.
Dashboard deep links use ?series=weekend or ?series=daily; chart permalinks
add a stable fragment such as #chart-weekend-lfb-spread. The README badges use
those series links, and clicking a chart image opens the corresponding SVG at
full resolution.
Peak CPU steps up through three representations as richer data appears, each
the honest chart for what exists: an aggregate line chart with a dashed
status-red line at 100% (one full core); small-multiples facets per core when
passive.cpu_peak_per_core_pct (core id → peak %) exists; and, preferred over
both, the cluster grid passive.cpu_peak_core_grid_pct (node id → core id →
peak %), rendered as the latest run's node × core utilization heatmap — cells
on a cool-to-hot (Jet) ramp whose domain is pinned so red always means "at or
beyond one full core", saturated cells (≥ 100%) carrying their printed value,
and the ramp legend drawn by the page (charton's own continuous colorbar
renders degenerate, so it stays suppressed).
The grid carries real core rows when the harness provides them: the
system-integration monitor samples per-CPU cgroup counters per node container
and emits resource-percore-timeseries.csv (a separate file from
resource-timeseries.csv so the aggregate extractors cannot double-count),
which the soak driver reduces to per-(node, core) peaks
(cpu_peak_per_node_core_pct per iteration) alongside the aggregate per-node
peaks (cpu_peak_per_node_pct). write-soak-summary.sh rolls both up
cell-wise: a node with per-core data in any iteration gets real core ids, and
a node with none anywhere — pre-emission history, or a provider without the
per-core hook — keeps a single "all" fallback row, so the same chart simply
grows taller as real core data appears.
The two publishers whose output can change history re-render both series; the
checkpoint publisher only carries the SVGs forward, since a checkpoint never
appends to history. Rendering is continue-on-error in the same spirit as the
badges: a chart bug must never block the publish that makes history durable —
the carried-forward SVGs from the previous publish stand instead.
Each run also records what it soaked: the target ref, the commit sha (linked to GitHub) and the node version declared at that commit — the same value carried by the Docker LABEL and image tag, so a dashboard row can be matched to a pulled image. Runs that predate version recording show a dash; history is never backfilled.
| 60h stability soak | Dev integration soak | |
|---|---|---|
| Window | Fri 19:30 → Mon 07:30 Pacific | Mon–Thu 19:30 Pacific |
| Duration | 60h, fixed | up to 22h, variable |
| Target | master |
dev |
| Launches | always | only if commits landed since the last window |
| Early exit | never | when the target branch advances, after an 8h floor |
| Benchmark segments | yes | no |
| Gates releases | yes | no |
| Regression verdict | fails the run | published, warns only |
| Restart on infrastructure failure | within the window, once | within the window, once |
A dev integration regression is published and shown on the dashboard's dev
integration (daily) tab but does not fail the workflow. Dev integration spans
vary — they stop early once dev advances —
so a run-over-run delta can reflect a shorter run rather than a real regression,
and failing on that would train people to ignore a red soak.
The 60h stability soak is exempt from the skip and the early exit because its numbers are the week-over-week baseline, and those are only comparable if every run covers an identical span. The dev integration runs trade that comparability for catching regressions sooner.
A soak publishes when it finishes, which for a 22h dev integration run means no visibility until the following afternoon, and for a 60h stability run means two and a half days of silence. Both series therefore publish checkpoints at 07:30 and 13:00 Pacific for every such instant inside the run:
| Run | Checkpoints | Segments |
|---|---|---|
| Dev integration (Mon 19:30 + 22h) | Tue 07:30, Tue 13:00 | 3 |
| 60h stability (Fri 19:30 + 60h) | Sat and Sun, 07:30 and 13:00 | 5 |
| 60h stability crossing spring-forward | the above plus Mon 07:30 | 6 |
The extra stability-soak checkpoint is not a rounding artefact: 60h from Friday 19:30 lands at Monday 08:30 rather than 07:30 once the clocks jump, which brings the Monday-morning instant inside the run.
Mechanically, the soak runs as consecutive segments sharing one output directory. The script resumes from a state file each time, so counters, the original start time and iteration numbering all continue and the run behaves as one continuous soak. Each segment except the last publishes what has happened so far.
A checkpoint carries no verdict. It reports status: in_progress, and the
dashboard shows running · Nh of Mh on the tab. A partial run has fewer
iterations, a lower peak RSS and a throughput figure over a shorter window than
the baseline it would be compared against, so a regression verdict at that point
would measure the clock rather than the code.
A checkpoint does not append to history. The charts and table show completed
runs only; a partial entry would double-count the night once the run finishes
and publishes for real. Only the latest-* files for that series are replaced,
and the dashboard says so while a run is in progress.
Checkpoint publishing is fail-soft throughout. The dispatch is a warning if it fails, and the soak continues — the run's real result still publishes at the end.
An infrastructure failure — no OCI capacity at launch, or a runner lost mid-run — used to forfeit the entire night, because the next launch is a day away. A soak can instead be restarted within its original window: the restarted run ends at the instant the original would have, so it can never overlap the next scheduled slot, and it publishes what it did cover rather than nothing at all.
The retry_within_window job re-dispatches the workflow once, for the
remainder of the window, when the run died of infrastructure rather than
results:
| Outcome | Restarts? |
|---|---|
| Runner launch failed | yes |
| Soak job failed without reaching its completion marker (VM preempted, reaped, frozen) | yes |
| Soak completed with failing iterations (red verdict) | no — that is a real result, not a lost run |
| Run cancelled by hand | no — an operator calling the night off is a decision, not a fault |
| Job timeout, or next slot's concurrency cancel | no |
| A restart that itself fails | no — the chain caps at one |
A lost runner surfaces as job failure, not cancellation, which is what lets "never restart a cancellation" and "always restart a lost VM" coexist. The cap is one attempt: a second failure in the same window is a pattern worth investigating, and OCI capacity shortages — the common launch failure — rarely clear within the night. Below a 2h floor the restart is skipped entirely; bring-up alone costs ~20 minutes, and a shorter remainder is not worth a VM.
scripts/restart-soak.sh dispatches the same restart mode from an operator
machine. It needs gh (authenticated), jq and python3:
scripts/restart-soak.sh --last-failed # infer everything from the last failed run
scripts/restart-soak.sh --series daily --until-next-slot
scripts/restart-soak.sh --series weekend --hours 12
scripts/restart-soak.sh --series daily --hours 4 --dry-run--last-failed finds the most recent failed scheduled run and recomputes the
window its cron slot defined (Friday 19:30 Pacific → weekend on master,
Mon–Thu → daily on dev), then restarts for whatever remains. The explicit
form takes --until-next-slot (end 30 minutes before the next 19:30 Pacific
launch — the shape for a same-day validation run that finishes and publishes
without touching tonight's schedule), --hours N, or --window-end EPOCH. It
confirms before dispatching unless given --yes, and mirrors the 2h floor
client-side so a doomed dispatch is refused locally.
Every restart — automatic or manual — is stamped in the published data:
run.restarted, run.retry_attempt, and run.window_seconds (the series'
nominal 22h/60h span, against duration_seconds, which for a restart is only
the remainder). The report header says so:
- **Run:** 30516534214, 150000s soak — restarted; covered 40h of the 60h window
A restarted run is never used as a baseline. It covers a partial span, so its lower iteration count and peak RSS would make the next full-window run look like a regression purely because the spans differ. The week-over-week comparison skips it and reaches back to the last complete run. This is also why the manual script always stamps its dispatches as restarts, even when the operator is simply redeploying by hand: a short ad-hoc run must never become the bar a real 60h weekend is judged against.
Checkpoints still work — the shortened window is enumerated for the 07:30 and 13:00 Pacific instants that remain inside it.
A manual dispatch can set skip_integration_preflight=true to start the soak
segments without the full integration preflight, for any duration:
gh workflow run merge-recovery-soak.yml --ref dev \
-f target_ref=master -f duration=weekend-60h -f skip_integration_preflight=trueUse this mode when a known defect blocks the preflight and the soak data is
still wanted. The flag is manual-only: a scheduled slot refuses it, and it
cannot combine with preflight_only or canary. The automatic in-window
restart carries the flag forward, so a restarted run skips the preflight too.
A run that skipped the preflight does not qualify as a release gate. No
integration preflight commit status is written for it, and the published
summary and verdict carry integration_preflight: "skipped" and
gate_qualifying: false (also stamped into run.integration_preflight in
the history entry). The soak numbers are real and stay in the series. The
candidate is not promotable on that run.
The page loads its data with fetch(), which browsers refuse over file://,
so opening index.html directly shows a permanently empty page. Use:
scripts/preview-soak-dashboard.sh # synthetic sample data, port 8770
scripts/preview-soak-dashboard.sh --live # data from the published site
scripts/preview-soak-dashboard.sh --empty # the bootstrap (no data) stateSource is .github/dashboard/; the server and sample-data generator are one
std-only Rust program built with plain rustc (no crates, no Cargo.toml,
nothing added to the workspace). The sample fixtures are deterministic and
deliberately include a regressed run, so the failure styling is exercised
without hand-editing anything. Everything generated lands in the gitignored
site/, rebuilt on start and removed on exit.
The chart SVGs are rendered from the seeded (or fetched) history when cargo
is available, by building scripts/soak-charts — the one part of the preview
outside the std-only guarantee, so it is best-effort: without cargo the chart
figures simply hide themselves, and --live falls back to the published SVGs
it fetched via the charts manifest.
Passive (the soak's own load, per iteration, rolled up per run):
| metric | source |
|---|---|
| failure rate | pytest results per iteration |
| throughput (iterations/hour) | wall-clock per iteration |
| peak node RSS | harness --monitor resource timeseries |
| peak node CPU | harness --monitor resource timeseries |
| finalization latency p50 / p95 / p99 | f1r3fly.propose.timing node logs |
| too-far-ahead errors | count of proposal rejections logged as too far ahead of the last finalized block, node logs |
| LFB convergence spread (p95 / max) | SOAK_METRIC registry (scripts/bench/soak-metrics.json), track-only |
Active (controlled-rate benchmark segments interleaved every Nth iteration —
a fresh local shard flooded at a fixed deploy rate for run-over-run comparable
latency): p50/p95 submit→finalize latency, throughput, finalization rate,
peak RSS. See scripts/bench/run-bench-segment.sh.
Extends the passive rollup with metrics modelled on asi-chain-testbed's
pkg/metrics chain-health charts, wired through the existing per-iteration →
per-run → dashboard pipeline:
- Finalization latency p50 / p99 — the same
f1r3fly.propose.timingtotal_mssamples already used for p95 (iteration_finalization_latencyinscripts/run-merge-recovery-soak.sh), now also read at the 50th and 99th percentile. Rolled up per run as the median of each iteration's percentile (matching how p95 was already rolled up), and charted as three lines ("Finalization latency percentiles"). - Too-far-ahead errors — count, per iteration, of the exact log line
"Proposal failed: too far ahead of the last finalized block"(casper/src/rust/blocks/proposer/propose_result.rs:185), summed across all iterations in the run. Distinct from finalization latency: this counts proposals the node refused outright rather than how slow finalization was. Charted as "Too-far-ahead errors". - LFB convergence spread (p95 / max) — the
lfb_spreadmetric already declared inscripts/bench/soak-metrics.json(max−min last-finalized-block across shard nodes) is read from the harness's structuredSOAK_METRICoutput. During the transition, the driver also extracts the existingAll-node LFBs at drain … (spread N blocks)pytest line, so the dashboard does not depend on synchronized deployment of both repositories.run-merge-recovery-soak.shnow folds every registry-declared metric intosummary.json.tracked_metricsgenerically (max/min aggregates fold with cross-iteration max/min, any other declared aggregate — p50, p95, ... — folds with the cross-iteration median), so declaring a new metric insoak-metrics.jsonis enough for it to reach a run'sweekly-summary.jsonand chart, no further code change. Charted as "LFB convergence spread". - Peak node CPU — the harness's
resource-timeseries.csvalready carried acpu_percentcolumn alongside thememory_mbonerss_peak_mbreads (elapsed_s,node,memory_mb,cpu_percent,memory_limit_mb); only the RSS column was being read.iteration_cpu_peak_percentinscripts/run-merge-recovery-soak.shsumscpu_percentacross shard nodes per poll tick and takes the peak tick for the iteration, exactly mirroringiteration_rss_peak_mb's treatment ofmemory_mb. Rolled up per run as the max across iterations. Charted as "Peak node CPU".
All four are track-policy metrics: recorded and charted only, they do not
enter the week-over-week gate in soak-gate-thresholds.json.
Thresholds live in one file: scripts/bench/soak-gate-thresholds.json
(maintainer-approved 2026-07-15). Week-over-week, the passive metrics fail
the soak run: failure rate +5pts, peak RSS +20%, finalization p95 +20%,
throughput −20%. Active-segment metrics warn.
Releases are gated: while the latest published soak verdict is regress,
the release.yml workflow holds the bump/tag without failing anything — the
gate job stays green (soak state belongs to the badges and dashboard, not to
the commit's build status), the release job is skipped, and the hold is
surfaced as a neutral release-held check run on the commit plus a
::warning:: annotation on the gate job. A held release is quiet by design:
no red ✗ appears; the release simply does not happen until the regression is
fixed or overridden. The gate also holds when the verdict cannot be fetched
(network error, 5xx, malformed JSON) — only a true 404 (pre-bootstrap
dashboard) lets the release proceed. Maintainer override: include
[soak-override] in the release commit message (the gate logs a warning and
proceeds).
The topic is soak-benchmark-reports (provisioned via
scripts/oci/create-ons-topic.sh; its OCID is the repo Actions variable
SOAK_ONS_TOPIC_OCID). The same script subscribes recipient addresses —
pass them as arguments (or via SUBSCRIBER_EMAILS); re-running it is safe,
already-subscribed addresses are skipped:
COMPARTMENT_OCID=<compartment-ocid> \
scripts/oci/create-ons-topic.sh user@example.com admin@example.comONS emails each new address a confirmation link; the recipient confirms to
start receiving alerts and every mail carries an unsubscribe link. The
recipient list is stored in OCI (view it with oci ons subscription list --topic-id <topic-ocid> ...) — never in this repository.
Removing an address (maintainer-side, e.g. a departed team member or a mistyped address — recipients can always just use the unsubscribe link):
oci ons subscription list \
--compartment-id <compartment-ocid> \
--topic-id <topic-ocid> --all # find the subscription OCID by endpoint
oci ons subscription delete --subscription-id <subscription-ocid>- Benchmarks run only in the 60h stability soak (
duration_seconds == 216000); see 60h stability vs dev integration for the full split. - The dashboard site deploys from the soak workflow via GitHub Pages
(Settings → Pages → source "GitHub Actions" must be enabled once). A separate
workflow,
soak-dashboard-pages.yml, redeploys the page shell when.github/dashboard/changes, preserving any already-published data. - Metric emission is fail-soft end-to-end: a broken segment or missing sample never fails the soak; only the weekend verdict comparison can.
- Iteration failures do not stop the run. A failed iteration is counted,
the soak sleeps 30s and continues, and the run exits red at the end with its
metrics intact. The script runs under
set -uo pipefailand deliberately never enableserrexit: metric collection returns nonzero when a failed iteration leaves nothing to sample, and making that fatal killed every segment mid-loop before any state or rollup was written. - The harness memory guards are sized to the host, and deliberately fire
before the kernel does. The harness default ceiling (5000MB) is
laptop-scale, while the 6-node shard legitimately peaks 16.7–19.3GB under
test_load(measured 2026-08: CI runs 30906818259 / 31332864501, with per-validator skew up to ~5GB on one node), so the workflow pins explicit values sized to the 48GB soak VM:SOAK_RSS_CEILING_MB=28672(~1.5× the measured envelope — never fires on legitimate load, catches a real leak attributably) andSOAK_HOST_FREE_FLOOR_MB=8192. The sizing invariant, learned twice in 2026-08 (PR #217 review, then run 31390673884):ceiling + ~7GB host overhead + floor ≤ MemTotal, otherwise the free-RAM floor fires before the RSS ceiling can, replacing the kill that names the overweight component with one that only names host pressure. The floor is enforced at two layers: pytest's--host-free-floor-mb(subprocess iterations only) and the orchestrator host guardian inrun-merge-recovery-soak.sh, which watches host free RAM on EVERY iteration, docker included. The 12GB derivation reserve (used whenSOAK_RSS_CEILING_MBis unset) exists to keep the kernel OOM killer — whose victim vanishes mid-step with no log — behind the watchdog; explicit values must preserve that property. Override withSOAK_HOST_RESERVE_MB,SOAK_RSS_CEILING_MB,SOAK_HOST_FREE_FLOOR_MB;0disables a guard, which is not recommended. Sustained RSS growth is policed week-over-week by the regression gate, not by these limits. - Soak runners are exempt from the CI reaper, with an expiry.
ci-runner-reaper.ymlterminatesci-eph-*instances older than 2h, which would kill any healthy soak mid-run. The soak job tags the instance it is itself running on — read from IMDS, written with instance-principal auth — withsoak-deadline-epoch(window end + 2h grace, alongsidepurposeandseries), and the reaper skips it until then. It is deliberately not the launch job that tags: ephemeral runners register by label, so GitHub routes the job to whichever matching runner claims it first, which is frequently an idle runner from an earlier launch rather than the VM just created. Tagging at launch therefore exempted the wrong machine (run 30590630059) and handed reaping immunity to VMs that never received work. A leaked soak VM still dies, just later; an untagged or expired one is reaped on the normal rule. Tagging fails closed — if it fails, the soak fails immediately rather than dying silently at hour two. - First run bootstraps: no baseline → verdict passes and seeds the history.