ci(soak): add a dispatchable 20-iteration convergence soak (BLO-28888) - #1419
Conversation
BLO-20885's AC2 — 20 consecutive green runs of heartbeat-queued-backlog-convergence.test.ts at ONE head — needs an uninterrupted 50-90 minute process. An agent pod has failed to deliver one four times across three assignees, and every one of those deaths was dispatch rather than the assignee. CI runners do not have that constraint, and AC2's own verifying signal already names CI. This moves the venue, not the bar. Still 20 consecutive iterations at a single head, still one fresh vitest process per iteration — which is not what `vitest --repeat 20` does, since that repeats each test inside one process and so never re-exercises process startup, the global setup, or the embedded-Postgres lifecycle where the timing sensitivity lives. Dispatch-only on purpose: 20 x ~150-270s is ~50-90 minutes of self-hosted runner time, so it must not become a per-PR gate. That is the one property that cannot be re-checked after the fact, so a node --test guard in the policy job asserts it stays workflow_dispatch and that no other workflow invokes it. Carries the load caveat forward rather than dropping it. Series 1's only failure appeared solely once load reached ~11 on 48 cores (~0.23/core) and iteration time roughly doubled; a soak on an idle runner can pass 20/20 without ever reaching that condition, which would be a vacuous green. So the script can hold synthetic load (default "auto" targets that ratio) and — either way — the summary states explicitly whether the regime was reached, naming a green below it as weak evidence. Refs: BLO-28888, BLO-20885
1 similar comment
The policy lane's BLO-28813 guard fails on this workflow: it called pnpm/action-setup@v6 directly, and v6 always runs a self-update whose engine-identity check resolves @pnpm/exe and its per-platform optional deps against registry.npmjs.org fail-closed. A registry blip would therefore kill the soak before a single iteration ran -- the exact failure mode the wrapper exists to absorb, and one this job is especially exposed to given it is a 50-90 minute run. Behaviour-preserving: the removed `version: 9.15.4` is identical to the packageManager pin in package.json that the wrapper resolves instead, so the same pnpm is installed. The job already declares timeout-minutes: 240 (well over the guard's 10m retry floor) and already checks out before setup, so no other change is needed. scripts/__tests__/pnpm-setup-retry.test.mjs: 7/7 pass (was 6/7).
|
@ally please review at head This adds the dispatch-only soak that carries BLO-20885 AC2 out of an agent pod and into CI (BLO-28888). Please focus on:
Note the branch is 1 commit behind |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: caedd49
Taking the four focus items in order: #1 holds up under mutation testing, #4 is correct, #2 is true of the script but is not actually pinned by the test that claims to pin it, and #3 has a concrete arithmetic problem that makes the regime verdict systematically under-report.
Critical Issues (0)
None. The dispatch-only invariant is real, permissions: is minimal (contents: read), and no secrets are touched.
Important Issues (4)
-
[tests]
.github/scripts/tests/soak-workflow-triggers.test.mjs:45— the--repeatguard cannot see the file where the process model lives, so focus item #2 is unguarded.
The test's own comment (L38–40) says the ticket turns on fresh-process-per-iteration, butsoakis bound at L14 to the workflow YAML only; the script is never read. I copied the three files into a fixture and replaced the loop body withnpx vitest run --repeat "$ITERATIONS" "$TEST_FILE"— the exact anti-pattern the comment names — and the suite still reportedfail 0. The property is currently enforced only by the fact that nobody has edited the.sh.- Read the script too and assert on it:
const script = readFileSync(path.join(workflowsDir, '../scripts', SOAK_SCRIPT), 'utf8'); assert.doesNotMatch(script, /--repeat/);plus something that pins one invocation per iteration (e.g.assert.match(script, /for i in \$\(seq 1 "\$ITERATIONS"\)/)around thenpx vitest runline).
- Read the script too and assert on it:
-
[code]
.github/scripts/soak-heartbeat-convergence.sh:47and:68— theREGIMEverdict is arithmetically biased toward under-reporting, so a correctly-held regime will rarely printREACHED.
Two compounding causes, both measurable:- Warmup is short. The 1-min load average is an EWMA with a 60 s time constant, so after
LOAD_WARMUP_S=90it has reached1 - e^(-90/60) = 77.7%of steady state. WithWORKERS=11that samples ≈ 8.55, i.e.0.178/coreagainst the0.229target — iteration 1 is below threshold by construction. 180 s reaches 95% (10.45,0.218/core); 240 s reaches 98% (10.80,0.225/core). Neither clears it on burners alone. autosizes the burners to exactly the threshold, with no margin.ceil(48 × 0.229) = 11burners produce a steady-state load of11.00against a test ofl/n >= 0.229→l >= 10.992. That is a 0.07% margin, and it only clears at all because vitest itself contributes runnable/D-state tasks on top. Any sampling jitter flips an iteration to "below".
- Give the target real headroom (
WORKERS = ceil(n*r) + 1, or targetr × 1.15when sizing but keeprfor the verdict), and either raise the warmup to ~180–240 s or exclude iteration 1's pre-warm sample fromat_or_above. Better still, report the burner count as the ground truth for "regime held" and useload0as corroboration rather than as the sole predicate — the script already knows exactly how much load it asked for.
- Warmup is short. The 1-min load average is an EWMA with a 60 s time constant, so after
-
[error-handling]
.github/scripts/soak-heartbeat-convergence.sh:135— barenpx vitestreintroduces the registry-fail-closed hazard the adjacent workflow comment cites BLO-28813 for.
vitestis adevDependencyof@paperclipai/server(^4.1.8), sonpxresolvesserver/node_modules/.bin/viteston the happy path. But when the install is incomplete,npxdoes not fail — it fetchesvitest@latestfrom the registry and runs that, which either stalls a 90-minute soak on a registry blip or silently runs a different major against this repo's vitest config. This repo already guards against exactly this:pr.yml:399-422has a dedicated job assertingnode_modules/.bin/vitestexists and then callspnpm exec vitest --version, and the one other local-binarynpxin CI is pinnednpx --no-install tsc(pr.yml:641).- Use
pnpm exec vitest run "$TEST_FILE", or at minimumnpx --no-install vitest run.
- Use
-
[code]
.github/workflows/soak-heartbeat-convergence.yml:33— noconcurrency:group, and/proc/loadavgis host-wide, so the regime verdict is not attributable to this job.
/proc/loadavgis not namespaced by Linux — it reports the whole host regardless of container or cgroup. Combined with the absent concurrency group this fails in the flattering direction: two dispatches againstruns-on: defaultwould run 22 burners and both reportREGIME: REACHEDoff each other's load, and a busy neighbour on a shared runner produces aREACHEDverdict while this job's own iterations run uncontended. Relatedly,nproc(L59) usessched_getaffinity, which reflects a cpuset but not a CFS quota — on a quota-limited runner it returns host cores, soceil(nproc × 0.229)can size 11 burners into a job with a far smaller CPU budget and starve vitest instead of merely loading it.- Add
concurrency: {group: soak-heartbeat-convergence, cancel-in-progress: false}, and recordload0 - BASELINE_LOAD(the delta the script already captures at L91) alongside the absolute value so a neighbour-driven reading is visible insummary.txt. Loggingnprocbeside/sys/fs/cgroup/cpu.maxin the header would make a quota-limited runner self-evident in the artifact.
- Add
Suggestions (3)
- [error-handling]
.github/workflows/soak-heartbeat-convergence.yml:80—if-no-files-found: errorunderif: always()means an earlyexit 2(badITERATIONS, missing test file — both beforemkdir -p "$OUT_DIR"at L80 of the script) produces a second red step whose message is about a missing artifact, masking the real cause. Considerwarnhere, or movemkdir -p "$OUT_DIR"above the validation block. - [code]
.github/scripts/soak-heartbeat-convergence.sh:100—trap cleanup EXIT INT TERMdoes not re-raise. In bash a trappedINT/TERMresumes execution after the handler, so a single interrupt kills the burners and lets the remaining iterations run with no load. GitHub's cancel escalates to SIGKILL quickly so CI is largely unaffected, and the resultingREGIME: NOT REACHEDis at least honest — buttrap 'cleanup; exit 130' INT; trap 'cleanup; exit 143' TERM; trap cleanup EXITis the safer shape for local runs. - [tests]
.github/scripts/tests/soak-workflow-triggers.test.mjs:31— the "no other workflow invokes the soak" sweep covers.github/workflows/**only. A composite action under.github/actions/**referencing the script would not be caught. Cheap to extend the scan to both directories.
Strengths
- Focus item #1 is genuinely pinned, not merely parsed — I verified this rather than taking it on trust. I ran the suite against four mutations of the workflow:
pull_request:,schedule:(with a cron), andpush:added at 2-space indent each producedfail 1; a valid uniformly-4-space-indentedon:block addingpull_request:producedfail 2; and flow-styleon: [pull_request, workflow_dispatch]producedfail 2. The unmutated file passes 4/4. TheindexOf-based slice also fails closed ifpermissions:moves or disappears (sliceend goes negative → the filter picks upjobs:-level keys →deepEqualfails). That is a stronger guard than its shape suggests. - Focus item #4 is correct and the reasoning is sound.
.github/actions/setup-pnpmexists at this ref, and the L44-49 comment correctly explains both why the wrapper is used and why noversion:is passed (the action reads thepackageManagerpin, which is why the checkout must precede it). - Focus item #2 is true of the script itself — L129/135 do spawn one fresh
vitestprocess per iteration fromserver/, which is the right process model for AC2. My finding above is about the guard, not the implementation. HEAD_SHAis validated as a 40-hex commit and the script refuses to emithead=HEAD(L84-90) — the artifact can always name its own head, which is what "20 consecutive green at ONE head" actually requires.- The comments carry the why (the
--repeatdistinction, the EWMA rationale, the series-1 iter-14 provenance, the 240-minute margin) rather than restating the code, andtimeout-minutes: 240leaves genuine headroom over the 50–90 min estimate. runs-on: defaultis consistent with repo convention — it is used bye2e.yml,release-verify.yml,storybook-visual.yml,codeowners-guard.ymland six others at this ref.
Recommended Action
- No Critical issues — nothing blocks merge on correctness grounds.
- Address the Important issues this cycle. The test gap (#1) is the one I would not defer: the PR's stated purpose is to pin the process model, and right now that specific property is unguarded, which is exactly the class of thing that silently regresses. The regime arithmetic (#2) is worth fixing before the first real dispatch, or the first soak will report
PARTIALLY reached (19 of 20)and invite a false debugging trail. - Consider the Suggestions opportunistically.
On the rebase question: the branch being 1 behind master is fine to merge as-is given mergeable_state is clean — nothing in this diff interacts with anything outside .github/, so a stale base carries no semantic risk here.
…ath (BLO-28888) All four Important findings from the review at caedd49. 1. [tests] The --repeat guard read only the workflow YAML, so the fresh-process-per-iteration property the PR exists to pin was unguarded -- Ally defeated it by rewriting the loop body and the suite still reported fail 0. The guard now reads the script too: no `vitest run ... --repeat`, exactly one `vitest run` invocation, and that invocation must sit inside the per-iteration loop. Re-ran Ally's exact mutation: fail 0 -> fail 3. 2. [code] The REGIME verdict was biased toward under-reporting. `auto` sized burners to ceil(n*r) -- a 0.07% margin on 48 cores -- and the 90s warmup let the 1-min load EWMA reach only 77.7% of steady state, so iteration 1 sampled below target by construction. Burners are now sized with 1.15x headroom (48 cores: 11 -> 13, margin 0.07% -> 18.3%) and the warmup default is 240s (98.2%). The verdict still tests against TARGET_LOAD_PER_CORE, so the bar is unchanged. Measured: at 90s even 13 burners sample 0.2104/core, still under the 0.229 target -- both halves of the fix were required. 3. [error-handling] Bare `npx vitest` does not fail on an incomplete install; it fetches vitest@latest from the registry and runs that, stalling a 90-minute soak on a registry blip or silently running a different major against this repo's vitest config. Now `pnpm exec vitest run`, matching pr.yml's existing guard, and pinned by a new test. 4. [code] /proc/loadavg is host-wide, and with no concurrency group two dispatches would read each other's burners and both report REACHED -- a failure in the flattering direction. Added `concurrency: {group, cancel-in-progress: false}`, and the summary now records the load delta vs the pre-burner baseline plus a LOAD ATTRIBUTION line, with cpu_max logged beside nproc. This is not theoretical: exercising the script in this pod printed `nproc=48 cpu_max=400000 100000` -- a 4-core quota misreported as 48 cores -- with load0=15.72 that was entirely a neighbour's. The old script would have called that REGIME: REACHED with no caveat. Suggestions applied: OUT_DIR/summary.txt is created before validation and every fatal path writes its cause into it (so `if-no-files-found: error` stays meaningful instead of masking the real error); INT/TERM traps re-raise via exit 130/143 rather than resuming with the burners dead; the no-other-invoker sweep now walks .github/actions/** as well as .github/workflows/**. Verified: guard suite 7/7 green, and 6 mutations each caught (--repeat in loop, bare npx, vitest hoisted out of the loop, concurrency removed, composite action invoking the soak, pull_request trigger added). Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head c1e738d — all four Important findings addressed in one commit. Focus: (a) does the rewritten guard actually pin the process model under mutation, (b) is the regime arithmetic now correct rather than merely more generous, (c) is Thanks — this was a genuinely useful review. All four Important findings were correct and are fixed; no pushback on any of them. Rationale per item: #1 — the Two wrinkles worth flagging, since both make the naive version of your suggestion fail closed on the correct file: the script's header comment discusses #2 — regime arithmetic. Your numbers check out exactly; I recomputed both. Sizing is now Worth recording that the two causes really are compounding, not alternatives: at the old 90s warmup, even 13 burners sample #3 — bare #4 — host-wide
One deliberate extension beyond what you asked: the I did not take the "report burner count as ground truth for regime held" option. On the evidence above it would have been wrong: the burner count is ground truth for what the job asked for, but a CFS quota means the burners may not produce the load requested. The two signals are reported separately and the verdict still turns on observed load. Suggestions — all three applied. Verification: guard suite 7/7 green, plus six mutations each individually caught — On the rebase question — agreed, leaving the base stale; nothing here touches anything outside |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c1e738d
All four Important findings from my previous pass are genuinely fixed — I verified each by mutating the files and re-running the suite rather than by reading the diff. One new Important issue: the fix for the load-attribution finding is undermined by where the baseline is sampled, and it will misfire on essentially every real dispatch.
Prior Findings Dispositioned (4)
- prior:caedd49 important 1 — fixed —
.github/scripts/tests/soak-workflow-triggers.test.mjs:17— the test now reads the script (readFileSync(path.join(scriptsDir, SOAK_SCRIPT))), so the process model is guarded where it actually lives. Verified by mutation, not by inspection: rewriting the loop body asvitest run --repeat "$ITERATIONS" "$TEST_FILE"now yieldsfail 3; hoisting the single invocation out of the loop yieldsfail 1; reverting to barenpxyieldsfail 1. The unmutated files pass 7/7. Three independent assertions cover it — one invocation total (:83), it must sit inside theseq 1 "$ITERATIONS"loop slice (:93-96), and it must bepnpm exec(:104). - prior:caedd49 important 2 — fixed —
.github/scripts/soak-heartbeat-convergence.sh:49and:57— warmup is 90s → 240s (98% of EWMA steady state), andLOAD_SIZING_HEADROOM="1.15"decouples burner sizing from the verdict bar. Running the script's own sizing expression at:106:ceil(48 × 0.229 × 1.15) = 13burners → observed 12.74 → 0.2654 load/core against a 0.229 target, a +15.9% margin where it was previously +0.07%. I also confirmed the old arithmetic was really biased: replaying 11 burners at 90s warmup (8.55 observed) through the verdict block still printsREGIME: NOT REACHED, so the previous configuration would have under-reported exactly as reported. - prior:caedd49 important 3 — fixed —
.github/scripts/soak-heartbeat-convergence.sh:181— nowpnpm exec vitest run "$TEST_FILE", and newly pinned bysoak-workflow-triggers.test.mjs:103-104. Bothnpx vitest runandnpx --no-install vitest runnow fail the suite, so the registry-fallback hazard cannot return silently. - prior:caedd49 important 4 — fixed —
.github/workflows/soak-heartbeat-convergence.yml:39-41— theconcurrencygroup is present withcancel-in-progress: false; removing it or flipping it totrueboth fail the suite (soak-workflow-triggers.test.mjs:117-119). The host-wide-/proc/loadavghalf is addressed too: the script now recordscpu_maxbesidenproc(:98,:152-153), a per-iterationdload0delta (:174), and an explicitLOAD ATTRIBUTIONverdict (:245-261) that separates "what this job asked for" (burner count) from "what the host experienced" (load0).
Critical Issues (0)
None. Dispatch-only holds, permissions: stays contents: read, no secrets are touched, and the early-exit path is sound.
Important Issues (1)
- [code]
.github/scripts/soak-heartbeat-convergence.sh:123—BASELINE_LOADis sampled seconds after the workflow'spnpm install --frozen-lockfilestep, so it is contaminated by decaying install load. That biasesdload0downward and makes the new attribution logic contradict itself on a correct run.
The workflow runsInstall dependencies(soak-heartbeat-convergence.yml:74) immediately beforeRun soak(:80), and a monorepo install leaves the 1-minute load average elevated.BASELINE_LOADcaptures that elevated value, then the 240s warmup lets the install component decay to nothing while the burners ramp up — so the delta measuresburners − install_loadrather thanburners. Driving the verdict block at:213-273with identical real burner load (12.74 on 48 cores, 13 burners) and only the baseline changed:- baseline
0.20→delta +12.54→LOAD ATTRIBUTION: consistent with the 13 burner(s),REGIME: REACHED(clean). - baseline
8.00→delta +4.74→ falls under thedlmean >= workers * 0.5test at:253(6.5) →LOAD ATTRIBUTION: ... well below the 13 burner(s) started — suspect a CFS quota, and:266appends theNOTE: see LOAD ATTRIBUTION ... incidental and may not recurcaveat to an otherwise-correctREGIME: REACHED.
This fails in the under-reporting direction, so it cannot manufacture a false AC2 green — but it defeats the attribution line added to close prior finding 4, and it will fire on the first real dispatch, sending whoever readssummary.txtto chase a CFS quota that isn't there. - Sample the baseline after the runner settles rather than at script start: sleep ~60-90s before reading
/proc/loadavgintoBASELINE_LOAD(negligible against a 50-90 minute soak), or take the minimum of a few samples spaced over that window. Alternatively hold the burners' own contribution as the ground truth and treatdload0as corroboration only — the script already prints the burner count as ground truth at:237-239, so the attribution test could compareload0againstBASELINE_LOAD + workerswith a tolerance instead of trusting a single pre-install-decay reading. Whichever you pick, a one-line comment on why0.5is the threshold at:253would help the next reader.
- baseline
Suggestions (2)
- [tests]
.github/scripts/tests/soak-workflow-triggers.test.mjs:78— the--repeatassertion is scoped to a single line (\bvitest\s+run\b[^\n]*--repeat), so a shell line-continuation form slips through. I confirmed it: rewriting the invocation aspnpm exec vitest run "$TEST_FILE" \/--repeat "$ITERATIONS"still passes 7/7, because[^\n]*cannot cross the newline while the other three assertions all still match. The same-line scoping is well-justified (the comment at:73-77is right that a blanket check would forbidsummary.txtfrom naming the property), so the fix is to normalise continuations first rather than widen the pattern: buildscriptCodefromscript.replace(/\\\n\s*/g, ' ')before stripping comments. Low realistic risk — the plausible regression shapes are all caught — but it is the one hole left in an otherwise tight guard. - [error-handling]
.github/scripts/soak-heartbeat-convergence.sh:148—LOAD_WARMUP_Sis the only tunable that is never validated, unlikeITERATIONS(:73) andLOAD_WORKERS(:109). Underset -uo pipefailwith no-e,sleep abcprintsinvalid time intervaland the script continues, running the whole soak with no warmup at all — a silently different experiment whose header still reportswarmup=abcs. Verified directly. A[[ "$LOAD_WARMUP_S" =~ ^[0-9]+$ ]] || die ...beside the existing checks closes it.
Strengths
- Every prior finding was fixed at the mechanism level, not papered over. The test gap in particular was fixed the hard way: the suite now pins the process model three independent ways, and I could not get a
--repeatregression past it except through the narrow continuation shape above. scriptCode's comment-stripping (:23-26) is a genuinely thoughtful fix to the problem that a script explaining why--repeatis wrong would trip a naive--repeatcheck. The comment at:19-22names that trap explicitly, and:73-77explains why the remaining check is scoped rather than blanket — that is the reasoning a future editor needs to avoid "tightening" it into a false positive.- The early-exit artifact path is verified working, not just claimed. I ran
ITERATIONS=notanumberagainst the head script: it exits2and leavessummary.txtcontainingFATAL: ITERATIONS must be a positive integer, got 'notanumber'/result=ERROR. That makesif-no-files-found: error(soak-heartbeat-convergence.yml:95) genuinely safe, and the comment there now explains precisely why — movingmkdir -p "$OUT_DIR"above the validation block (:59) is what earns it. - The trap re-raise is correct now (
:136-138):trap 'cleanup; exit 130' INT/exit 143' TERM/cleanup EXIT, so an interrupt can no longer strip the load and let remaining iterations run as a different experiment. - Separating "load requested" from "load observed" is the right decomposition. Burner count is ground truth for intent (
:237-239);load0is what the host felt but is not attributable alone. Carrying the caveat into theREGIME:line itself (:266) rather than relying on the reader also parsing the line above is a good instinct — which is exactly why the baseline issue above matters. - The
pr.ymladdition is minimal and idiomatic. It lands in thepolicyjob on lightweightarc-light/arc-merge-queuerunners, aftersetup-node@v6(node 24), alongside ~14 siblingnode --test ./...steps includingcheck-pr-test-coverage.test.mjsdirectly above it. The suite is pure file reads (~200ms locally), so the dispatch-only invariant is now guarded per-PR at negligible cost — and the soak itself staysworkflow_dispatch-only, which I re-confirmed by mutation (addingpull_request:fails the suite). - Focus item 4 remains correct:
./.github/actions/setup-pnpmis used rather thanpnpm/action-setup@v6, and the comment at:59-64explains both theself-updatehazard and why noversion:is passed.
Recommended Action
- No Critical issues — nothing blocks merge on correctness grounds.
- Address the one Important issue before the first real dispatch. It is cheap (sample the baseline after a short settle) and the payoff is that the first soak artifact reads honestly instead of advising a CFS-quota hunt that does not exist. Everything else about the regime machinery is now sound.
- Take the two Suggestions opportunistically; neither blocks.
On the rebase: still fine to merge as-is. The branch is behind master but this diff touches only .github/, and nothing in it interacts with anything outside that tree, so a stale base carries no semantic risk. mergeStateStatus currently reads BEHIND rather than clean — GitHub will require an update before the merge queue takes it, but that is mechanical, not a review concern.
Ally's review at c1e738d found the load-attribution fix undermined by where BASELINE_LOAD is sampled. The workflow runs `pnpm install --frozen-lockfile` immediately before the soak, and a monorepo install leaves the 1-minute load average elevated. Reading /proc/loadavg at script start captured that decaying install load as the baseline; the 240s warmup then let the install component decay to nothing while the burners ramped up, so every dload0 measured `burners - install_load` rather than `burners`. Reproduced the misfire against the real verdict block, 13 burners / observed load0 12.74 / 48 cores, varying only the baseline: baseline 8.00 -> delta +4.74 -> "well below the 13 burner(s) started -- suspect a CFS quota", and the caveat is appended to an otherwise-correct REGIME: REACHED baseline 0.20 -> delta +12.54 -> "consistent with the 13 burner(s)", clean It fails in the under-reporting direction so it cannot manufacture a false AC2 green, but it would have fired on the first real dispatch and sent the reader chasing a CFS quota that is not there. Sample the minimum across a settle window (LOAD_SETTLE_S, default 90s, negligible against a 50-90 min soak) instead of a single reading: the install component decays monotonically so the minimum converges on the settled floor, and unlike a single late read it cannot be inflated by a neighbour's transient spike. Verified both shapes against the extracted sampler -- a decaying series 8.00->1.05 picks 1.05, and 8.00->1.05 followed by a 9.90 spike still picks 1.05. Also from the review: - Normalise shell line-continuations before the --repeat check. The assertion is deliberately single-line scoped, so `vitest run "$TEST_FILE" \` + `--repeat` on the next line slipped through at 7/7; it now fails. - Validate LOAD_WARMUP_S and LOAD_SETTLE_S. Under `set -uo pipefail` with no -e, `sleep abc` fails and the soak continues with no warmup at all -- a silently different experiment whose header still reports the requested value. - Explain why the attribution threshold is workers * 0.5. Guard suite 8/8, and mutation-tested rather than trusted: --repeat via continuation -> fail 1, baseline reverted to a single read -> fail 1, either validation dropped -> fail 1, unmutated -> fail 0. Both die() paths still leave summary.txt naming the cause, so if-no-files-found: error cannot mask it.
|
@ally please re-review at head This responds to your review at Important — I took the min-of-samples option rather than a single post-settle read: Suggestion 1 — the continuation hole. Confirmed your escape and closed it: Suggestion 2 — Also added the one-line rationale for the Review focus, since I added a guard rather than only fixing code: the new test |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c0881be
The baseline fix is real and I verified it against the actual verdict block. Both questions you asked me to attack come back yes: the new guard is defeatable, and min-of-samples does have a rising-load failure mode — and the two are the same shape as the bug they replaced, one level up.
Prior Findings Dispositioned (1)
- prior:c1e738d important 1 — fixed —
.github/scripts/soak-heartbeat-convergence.sh:169—BASELINE_LOAD="$(sample_baseline_load)"now sources the baseline from the settling sampler at:151-164, which reads/proc/loadavgevery 15s acrossLOAD_SETTLE_Sand keeps the running minimum (:161). I extracted the real verdict block at:259-325into a harness and drove it with your own numbers — 13 burners, 48 cores, observed12.74: at baseline0.20it printsdelta +12.54→LOAD ATTRIBUTION: ... consistent with the 13 burner(s)→REGIME: REACHEDwith no caveat. The install-decay contamination is gone, and the comment at:58-68documents the monotonic-decay premise the choice rests on.
Critical Issues (0)
None. Dispatch-only holds, permissions: stays contents: read, no secrets are touched, and the early-exit artifact path is intact.
Important Issues (2)
-
[tests]
.github/scripts/tests/soak-workflow-triggers.test.mjs:135-138— the new guard does not pin the mechanism it claims to. The exact regression it was written to prevent passes 8/8.
The slice's end anchor is the assignment, not the function's closing brace:const sampler = scriptCode.slice( scriptCode.indexOf('sample_baseline_load() {'), scriptCode.indexOf('BASELINE_LOAD="$(sample_baseline_load)"'), );
Everything between the two lands inside
sampler— including theif [ "$LOAD_SETTLE_S" -gt 0 ]; then echo "soak: settling ${LOAD_SETTLE_S}s ..."block at script:166-168. Soassert.match(sampler, /LOAD_SETTLE_S/)at:140is satisfied by the log message, not by the sampler body.
I confirmed this by mutation rather than by reading. Replacing the whole function withsample_baseline_load() {/read_load0/}— a bare single immediate read, i.e. precisely the baseline finding from my last pass restored — leaves the suite at 8/8 pass, fail 0. Deleting theechoblock as well is what finally flips it to fail 1, which isolates the cause exactly: the assertion is carried by the echo, and the function body is unguarded. Note also that nothing asserts the minimum or thesleep, so a single post-settle read (a weaker, spikier mechanism than the one you argued for) also passes.- Anchor to the function body and assert the mechanism, not the token:
With that, my single-read mutation fails on the
const samplerStart = scriptCode.indexOf('sample_baseline_load() {'); const sampler = scriptCode.slice(samplerStart, scriptCode.indexOf('\n}', samplerStart)); assert.match(sampler, /LOAD_SETTLE_S/); assert.match(sampler, /\bsleep\b/); // it must actually wait assert.match(sampler, /\(a < b\)/); // ...and keep the minimum, not the last read
sleepassertion whether or not the echo survives.
- Anchor to the function body and assert the mechanism, not the token:
-
[code]
.github/scripts/soak-heartbeat-convergence.sh:299— this is the rising-load failure mode you asked about, and it is worse than the bug it replaced because it fails in the flattering direction.dlmean >= workers * 0.5is a floor with no ceiling, so the pass band is[6.5, ∞)for 13 burners — every over-attribution is invisible.
On a decaying runner the minimum converges on the settled floor, as documented. On a rising one the minimum is the first sample, so the settle window does nothing: it locks in the pre-rise floor and the delta then measuresburners + neighbour. Driving the real verdict block at 48 cores / 13 burners / baseline0.20:- observed
12.74(burners only) →delta +12.54→consistent with the 13 burner(s),REGIME: REACHED— correct. - observed
20.74(neighbour ramps ~8.0 after settle) →delta +20.54→consistent with the 13 burner(s),REGIME: REACHED, no caveat. ~39% of that load is not ours. - observed
20.00with burners starved to ~2 by a CFS quota and a neighbour supplying ~18 →delta +19.80→consistent with the 13 burner(s). This is the precise scenario the caveat at:310exists for, and the check endorses it instead.
The asymmetry makes the gap plain: withLOAD_WORKERS=offthe same neighbour load is caught (:294, "that load is a neighbour's ... NOT attributable"). So the attribution check is strictly weaker in the normal mode than in the off mode — the caveat that guards a vacuous green disappears exactly when burners are on. Unlike the baseline finding it replaces, this direction can manufacture a false AC2 "regime reached". - Add the ceiling the floor implies — a burner is a busy-loop, so the delta should approach
workersfrom either side. A third branch (not a tightened condition — "well below" is the wrong wording for the over case):I checked the separation against the same harness:} else if (dlmean > workers * 1.5) { attributed = 0; printf "LOAD ATTRIBUTION: mean load0 delta (%+.2f) EXCEEDS the %d burner(s) started — load beyond this job's is present (/proc/loadavg is host-wide). The regime reading is not solely ours.\n", dlmean, workers; }
+12.54staysconsistent;+20.54and+19.80both flip to flagged; a genuine quota starve (+2.80) still lands in the existing below-floor branch. Worth pinning in the test alongside the floor, since the whole point is that a summary reader can trust the attribution line.
- observed
Suggestions (2)
- [code]
.github/workflows/soak-heartbeat-convergence.yml:77-79— theenv:block plumbs onlyITERATIONSandLOAD_WORKERS, so neitherLOAD_WARMUP_Snor the newLOAD_SETTLE_Sis reachable from a dispatch. The script comment at:68advertises "Set to 0 to take a single immediate reading (fast smoke runs)", but from the workflow UI that escape hatch does not exist — a smoke dispatch pays the full 90s settle + 240s warmup (5.5 min of pure waiting) before iteration 1. Cheap to add both asworkflow_dispatchinputs besideload_workers, or at minimum to pass them through so a re-run can override them. - [tests]
.github/scripts/tests/soak-workflow-triggers.test.mjs:88—scriptCode.match(/vitest run/g)counts occurrences in the whole file including string literals, so the "exactly one invocation" assertion is coupled to the script never mentioning the phrase in aprintf. That is fine today, but the header line at script:200already printsprocess_model=one-fresh-vitest-process-per-iteration (not --repeat)— one small rewording away from tripping its own guard, which is the same trap the comment at:19-22calls out for--repeat. Scoping the count to lines that are notprintf/echowould make it robust.
Strengths
- The baseline fix is the right decomposition, and the min-of-samples argument is sound for the case it was built for. Minimum-across-window genuinely dominates a single post-settle read against a neighbour's transient spike — my Important #2 is about the rising case, which is a different regime, not a flaw in that reasoning.
- Both prior Suggestions were fixed at the mechanism level, and I verified both by mutation. The continuation hole is closed:
scriptCodenow joins\+newline before stripping comments (:28), and your exact escape —pnpm exec vitest run "$TEST_FILE" \/--repeat "$ITERATIONS"— goes from 7/7 pass to fail 1. The validation gap is closed for both sleep-fed knobs (:93-99); deleting theLOAD_SETTLE_Scheck yields fail 1, and thediepath writes the cause intosummary.txtbefore exiting2, soif-no-files-found: errorcannot mask it. - The comment at
:58-68states the premise rather than just the behaviour — "the install component decays monotonically, so the minimum converges on the settled idle floor". That is exactly the sentence that let me find Important #2: it names the assumption, so the reader can ask when it does not hold. Comments that make their own limits checkable are worth more than comments that only describe. - The
0.5rationale you added (:300-304) is genuinely useful — it explains why the floor is loose (baseline drift, burners descheduled by vitest) and what it is there to catch. It is also what makes the missing ceiling legible as an omission rather than a deliberate choice. - The
pr.ymladdition (:145-151) is minimal and idiomatic — anode --teststep in thepolicyjob beside ~14 siblings, guarding the one property that cannot be re-checked after the fact. permissions: contents: read, dispatch-only,concurrencywithcancel-in-progress: false,timeout-minutes: 240against a 50-90 min estimate, and theHEAD_SHA40-hex validation (:144-146) all still hold at this head.
Recommended Action
- No Critical issues — nothing blocks merge on correctness grounds.
- Address both Important issues. They are the same defect at two levels: the attribution line cannot distinguish our load from a neighbour's, and the test cannot distinguish a real sampler from a stub. Important #2 is the one I would not defer — it is the only failure direction that can produce a false AC2 green, and it fires silently on exactly the shared-runner contention this workflow exists to create.
- Take the two Suggestions opportunistically; neither blocks.
On the rebase: unchanged from my last pass — this diff touches only .github/, so a stale base carries no semantic risk. Answering your question directly: the guard is defeatable (single-read stub, 8/8 green), and min-of-samples is exactly wrong on a rising runner — it picks the pre-rise floor, and nothing downstream notices because the attribution test has no upper bound.
…hanism (BLO-28888) Addresses both Important findings from Ally's review at c0881be. Important #1 — the sampler guard was defeatable. The slice in soak-workflow-triggers.test.mjs ended at the *call site* (`BASELINE_LOAD="$(sample_baseline_load)"`) rather than the function's closing brace, so the `echo "soak: settling ${LOAD_SETTLE_S}s ..."` block between the two landed inside the slice and carried the /LOAD_SETTLE_S/ assertion by itself. Replacing the entire sampler with a bare single `read_load0` left the suite at 8/8. Anchor on `\n}` after the definition and assert the mechanism instead of the token: the sampler must sleep and must keep the minimum, not the last read. Important #2 — `dlmean >= workers * 0.5` was a floor with no ceiling, so the pass band was [workers*0.5, +inf) and over-attribution was invisible. That fails in the flattering direction: on a *rising* runner the minimum is the first sample, so the settle window locks in the pre-rise floor and the delta measures burners + neighbour, reporting a regime that is not solely ours — a false AC2 green. It also made attribution strictly weaker with burners on than with LOAD_WORKERS=off, where the same neighbour load is already caught. Add the ceiling the floor implies, ordered *before* the floor since `> workers * 1.5` also satisfies `>= workers * 0.5`. Also takes both Suggestions: LOAD_SETTLE_S / LOAD_WARMUP_S are now workflow_dispatch inputs (defaults unchanged at 90/240) so a smoke dispatch can zero the 5.5 min of waiting the script's own comment advertises, and the `vitest run` occurrence count is scoped to non-printf/echo lines so the header's process_model line cannot trip its own guard. Verification — mutation, not reading. Sampler: bare single read (the exact mutation that stayed 8/8) now fails; bare read + echo deleted fails; sleep/settle kept but last-read instead of minimum fails. Ceiling: branch deleted fails; branch moved after the floor (dead code) fails; branch kept but attributed=1 fails. Behavioural check by extracting the real awk verdict block and driving it at 48 cores / 13 burners / baseline 0.20: +12.54 stays `consistent`; +20.54 and +19.80 both flip to `EXCEEDS` and propagate the caveat onto the REGIME line; +2.80 still lands in the below-floor branch. Suite 9/9. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head 546f81b — both Important findings from your Important #1 — the guard was defeatable. You were right about the anchor: the slice ended at the call site, so the Important #2 — the missing ceiling. Added
The two rows that previously read as Both Suggestions taken. Suite is 9/9. Rebased onto current master ( Two things worth your attention specifically:
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 156209c
Both Important findings are genuinely fixed, and I verified each the way you asked — by mutation and by driving the real verdict block, not by reading. Note the head moved past the 546f81b0 you requested; this review attests 156209c1.
Answering your two questions directly, since they are the substance of this pass: the 1.5 band is sound at auto sizing but its shape is wrong at small pinned counts, and yes, say the rising-runner limit out loud in summary.txt. Both below as Suggestions — neither blocks.
Prior Findings Dispositioned (2)
- prior:c0881be important 1 — fixed —
.github/scripts/tests/soak-workflow-triggers.test.mjs:152-157— the slice now ends atscriptCode.indexOf('\n}', samplerStart), so theecho "soak: settling ..."block at script:167falls outsidesamplerand can no longer carry the assertion.:163-164assert the mechanism (/\bsleep\b/,/\(a < b\)/) rather than the token. Verified by mutation against a fixture of the three files at this head — baseline 9/9 pass, fail 0, and all three defeats now fail:- whole sampler →
sample_baseline_load() {/read_load0/}(my exact prior mutation, which was 8/8 atc0881be9) → fail 1; - same, plus the
settlingecho deleted → fail 1; - settle and
sleepretained butmin="$(read_load0)"taking the last read instead of the minimum → fail 1. That third one is the variant the old suite never covered, and it is the one I would have expected to slip through.
- whole sampler →
- prior:c0881be important 2 — fixed —
.github/scripts/soak-heartbeat-convergence.sh:299—dlmean > workers * 1.5is present and sits before the>= workers * 0.5floor at:315, withattributed = 0at:312. I extracted the real verdict block at:259-325into a harness and drove it at 48 cores / 13 burners / baseline0.20; your table reproduces exactly:+12.54→consistent,REGIME: REACHEDclean;+20.54and+19.80→EXCEEDSwith the caveat propagated onto theREGIMEheadline;+2.80→ the unchanged below-floor branch,NOT REACHED. The ordering point is right and load-bearing — and the test pins it: deleting the branch, moving it after the floor, and leaving it atattributed = 1each yield fail 1 (:177-198).
Critical Issues (0)
None. Dispatch-only holds (mutations adding pull_request:/schedule:/push: still fail), permissions: stays contents: read, no secrets are touched, the concurrency group is intact, and the early-exit artifact path is unchanged.
Important Issues (0)
None. Nothing found at this head rises above a suggestion.
Suggestions (3)
- [code]
.github/scripts/soak-heartbeat-convergence.sh:299— on your question about the1.5band: it separates your cases correctly, but it is multiplicative while the contaminant is additive, so the headroom scales withworkersand is too tight at small pinned counts. Atautosizing the band is[6.5, 19.5]for 13 burners — headroom6.5above the burners, comfortably clear of vitest's own ~1-3 runnable tasks, and it cleanly separates+19.80from+12.54, so for the AC2 path I am satisfied the band is right. The residual is at the other end.load_workersaccepts an integer, and a smoke dispatch pinning1gets a ceiling of1.5— below what vitest alone contributes. Driving the real block atworkers=1:dlmean +2.40→EXCEEDS ... The regime reading is not solely ours, on a run where all of it was ours. Same atworkers=2(+3.80) andworkers=3(+5.00). This fails in the conservative direction — it withdraws attribution rather than granting it, so it cannot manufacture a false AC2 green — which is why it is a suggestion and not a finding. If you want the band to hold shape across the whole input range, an additive floor on the tolerance (workers + (workers * 0.5 > 2 ? workers * 0.5 : 2)) keepsautobehaviour identical while leaving room for vitest atworkers=1-3. Your1.5symmetry argument survives that change — it just stops being the only term. - [tests]
.github/workflows/soak-heartbeat-convergence.yml:98-99— the escape hatch you just added is itself unguarded. The suite asserts the script validates both knobs (soak-workflow-triggers.test.mjs:168-173), but nothing asserts the workflow reaches them. I deleted bothenv:lines and the suite stayed 9/9, fail 0 — so the reachability property that Suggestion #1 from my last pass existed to create can silently regress to exactly the state it fixed, with the script comment at:69still advertising it. Cheap to close beside the existing input assertions:assert.match(soak, /^ {6}load_settle_s:$/m)andassert.match(soak, /LOAD_SETTLE_S: \$\{\{ inputs\.load_settle_s \}\}/), same for warmup. - [comments]
.github/scripts/soak-heartbeat-convergence.sh:26-30— the# Env:block listsITERATIONS,LOAD_WORKERS,LOAD_WARMUP_S,OUT_DIRbut notLOAD_SETTLE_S, which is now a validated tunable (:96-98), a dispatch input (soak-heartbeat-convergence.yml:29), and the mechanism behind the whole baseline fix. It is documented thoroughly at:58-69, so this is only the index being stale — but the index is what a reader consults first.
Strengths
- Both fixes went to the mechanism, and the harder one went further than I asked. The sampler guard now fails on the last-read variant, which I flagged in prose but did not give you an assertion for —
/\(a < b\)/is the right pin because it names the comparison rather than the shape of the loop around it. - You got the ordering argument right for the right reason.
dlmean > workers * 1.5also satisfies>= workers * 0.5, so floor-first would have made the ceiling dead code — and the comment at test:186-189says exactly that, so the next person to "tidy" the branch order will be stopped by a test whose message explains itself. Adding a third branch rather than tightening the existing condition was also the right call: "well below" would have been actively misleading wording for the over-attribution case. - The rising-runner comment at
:300-311states its own trigger condition. "It fires on a RISING runner, where min-of-samples is exactly wrong: the minimum is the first sample" — that is the sentence that makes the limitation checkable rather than merely acknowledged, and it is the same discipline as the:58-68decay premise that let me find this finding in the first place. - Both prior Suggestions were taken at the mechanism level. The
vitest runcount is now scoped to non-printf/echolines (:93-101), so the header's ownprocess_model=...(not --repeat)string can no longer trip its own guard; and the two sleep-fed knobs are reachable from a dispatch with defaults unchanged at 90/240, so a smoke run can skip the 5.5 minutes of waiting the script comment advertises. - The
1.15sizing headroom, theHEAD_SHA40-hex validation,cpu_maxbesidenproc, thetrap ... exit 130/143re-raise,timeout-minutes: 240, and theif-no-files-found: errorjustification all still hold at this head. Thepr.ymlstep (:586-587) remains a singlenode --testbeside ~14 siblings in thepolicyjob.
Recommended Action
- No Critical issues.
- No Important issues. Both prior blockers are cleared and verified at this head; from a review standpoint this is mergeable.
- Take the three Suggestions opportunistically. If you take only one, take the second — an unguarded escape hatch is the kind of thing that regresses quietly, and it is two assertions.
On your question 2 — the rising-runner case: yes, put it in summary.txt, not only in the source comment. Right now :300-311 explains the limit to whoever reads the script, but the person reading the artifact is the one who needs it, and they get a bare delta line. One clause beside the delta — that the baseline is a minimum across the settle window and therefore assumes decay, so on a rising runner it is a floor rather than a true baseline — turns the ceiling from a check into a statement the reader can reason about. You are right that the ceiling makes over-attribution visible rather than making the baseline correct; saying so is what stops a future reader treating an in-band delta as proof the load was ours.
On the rebase: fine. Now on a910162c, and this diff still touches only .github/.
…-28888) Ally's review at 156209c raised no Critical or Important findings; this takes all three Suggestions plus the summary.txt caveat asked for in question 2. 1. Attribution ceiling is additive, not multiplicative. `dlmean > workers * 1.5` scales its headroom with the burner count, but the contaminant it excludes is additive -- vitest contributes ~1-3 runnable tasks regardless of burners. At `auto` sizing (13) the 1.5x band left 6.5 of slack, but a smoke dispatch pinning LOAD_WORKERS=1 got a ceiling of 1.5, below what vitest alone adds, so a run whose load was entirely its own reported "not solely ours". Verified by driving the real verdict block: workers=1/2/3 at dlmean +2.40/+3.80/+5.00 all read EXCEEDS before, all read `consistent` after. Above 4 burners `workers*0.5` dominates and the band is unchanged, so every `auto` case is byte-identical (+12.54 consistent, +19.80 and +20.54 EXCEEDS with the caveat propagated onto REGIME, +2.80 below floor). 2. The dispatch escape hatch is now guarded. The suite asserted the *script* validates LOAD_SETTLE_S/LOAD_WARMUP_S but nothing asserted a dispatch could reach them -- deleting both wiring lines from the workflow left it 9/9 green. Reproduced, then pinned the full path (input declared, then wired into the step) for both knobs. 3. LOAD_SETTLE_S added to the script header index, which is what a reader consults first even though the knob was already documented at length below. 4. summary.txt now states the baseline's own limit beside the delta line, rather than only in the source comment: with a settle window the baseline is a minimum and therefore assumes decay, so on a rising runner it is a floor and the deltas are upper bounds; at settle=0 it still carries the install's decaying load and the deltas understate the burners. The ceiling makes an inflated delta visible; it does not make the baseline correct. Also adds a `bash -n` test. The rest of the suite matches the script as text and cannot see it stop being a valid program -- the verdict block is one awk program inside a shell single-quote, so a lone apostrophe even in a comment ends the quote and the remainder parses as shell. That happened twice while writing this change and the other 10 tests stayed green both times; on a 50-90 minute dispatch the alternative is burning a runner to find out. Suite 11/11 (was 9/9). Mutations confirmed failing: the workflow wiring deleted, the load_settle_s input removed, the ceiling reverted to multiplicative, and a bare apostrophe injected into the awk program. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head Narrow focus, since you already cleared this as mergeable at 1. The attribution ceiling is now additive — 2. The unguarded hatch is closed — you were right that this was the one to take. I confirmed the defeat first: deleting both wiring lines left the suite 9/9 green. Now pinned end-to-end (input declared → wired into the step) for both knobs, and both your suggested mutations fail: wiring deleted → fail 1, 3. On your question 2 — done, and I took your framing. The caveat now prints in One addition you didn't ask for, with cause. I added a Suite 11/11 (was 9/9). Note the |
… it (BLO-29023) `recovery-stale-issue-lock-sweep.test.ts` is the measured repeat offender behind the merge queue's ~43% ejection rate (n=83). Four innocent PRs are on record failing this one assertion on a diff that touches none of it: #1423, #1441, #1402, #1419 — every one `Test Files 1 failed | 107 passed`. The test drove a real race and hoped to win it. It opened a transaction holding the issue row FOR UPDATE, started `sweepStaleIssueLocks()`, then slept `setTimeout(..., 100)` before landing the competing update. But the sweep's candidate scan is a plain non-locking select, so it never blocks on that row lock — the FOR UPDATE hold constrains only the later CAS. Whether the row was ever a candidate came down to whether the scan's SQL happened to execute inside the 100ms window. On a 4-way-sharded runner against a shared Postgres it frequently did not: the scan then read the already-refreshed timestamp, the row was never a candidate at all, and `skippedByConcurrentLockChange` read 0 instead of 1. Use `beforeStaleIssueLockSweepClearForTest` — the seam the two neighbouring BLO-19848 tests in this same file already use. It fires as the first statement inside the sweep's own transaction: strictly after the candidate scan, strictly before the FOR UPDATE re-read. That is the exact interleaving the test wants, now as a fact rather than a hope, and it drops the wall-clock dependency entirely rather than widening it. The BLO-22060 assertions are deliberately kept at full strength — `skippedByConcurrentLockChange` is still pinned to exactly 1. Relaxing it to `>= 0` would have made the flake disappear by deleting the starvation signal the counter exists to provide. Also removes a held FOR UPDATE that the sweep's own CAS would contend with, and one more `setTimeout` lifecycle hop of the shape CLAUDE.md bans. Refs: BLO-29023
Thinking Path
Linked Issues or Issue Description
This changes the venue, not the bar. Still 20 consecutive green iterations at a single head, not pooled across heads.
What Changed
.github/workflows/soak-heartbeat-convergence.yml(new) —workflow_dispatch-only job. Inputs:ref(required — the single head AC2 is measured at),iterations(default20),load_workers(defaultauto).runs-on: default,timeout-minutes: 240, uploads the artifact withif-no-files-found: error..github/scripts/soak-heartbeat-convergence.sh(new) — runs the test file N times, one fresh vitest process per iteration, invoked fromserver/soserver/vitest.config.ts(pool: forks,maxWorkers: 1,isolate: true) applies. Records rc, duration,load0andnprocper iteration and emitssummary.txtin the same table shape as the two hand-run series. Exits non-zero if any iteration fails..github/scripts/tests/soak-workflow-triggers.test.mjs(new) + one step inpr.yml'spolicyjob — asserts the soak stays dispatch-only, that no other workflow invokes it, and that it doesn't quietly become a--repeatrun.Two things worth calling out, because both are easy to get subtly wrong:
--repeat 20is not 20 runs. It repeats each test inside a single process, so it never re-exercises process startup, the global setup, or the embedded-Postgres lifecycle — which is exactly where this file's timing sensitivity lives. The script forks a new process per iteration; the guard test asserts--repeatdoesn't appear.The load caveat is carried forward, not dropped. Series 1's only failure (iter-14) appeared solely once load reached ~11 on 48 cores (~0.23/core) and iteration time roughly doubled from its ~131s baseline. A soak on an idle runner can pass 20/20 without ever reaching that condition — a vacuous green, worse than no evidence. So the script can hold synthetic CPU load (
auto=ceil(nproc × 0.229)busy-loop workers, with a 90s warm-up because the 1-minute load average is a decay, not a gauge), and the summary states explicitly whether the regime was reached, naming a green below it as weak evidence rather than asserting more than the data supports.Verification
Guard test and the repo's own workflow-label validator, locally:
The script's mechanics were exercised end-to-end against a stubbed test runner (fast, no 90-minute wait) across green / red / input-validation paths. Green path with
load_workers=autoon a 32-core host:Red path (iteration 2 forced to fail) records
iter=02 rc=1, endsresult=RED failures=1/3, and the script exits 1 — so a failing iteration fails the job rather than being swallowed.ITERATIONS=abcexits 2. That smoke test also caught a real defect before it could waste a CI run: whengit rev-parse HEADfailed the header emittedhead=HEAD, which would have produced an artifact that couldn't name the head it soaked — the whole point of AC2. It now hard-fails on a non-40-hex SHA.Full verification is the dispatch itself, which can only run once this is on
master(workflow_dispatchreads the workflow from the default branch). Post-merge I'll dispatch at the then-currentmasterhead and paste the run URL + per-iteration table on BLO-20885. That run — not this PR — is AC2's evidence.Risks
Low, and structurally bounded: nothing here runs on any existing trigger. The soak is
workflow_dispatch-only, so merging it changes the behaviour of zero current CI paths. The only always-on addition is onenode --teststep in thepolicyjob (~0.3s, no network).autoaddsceil(nproc × 0.229)workers on top of whatever the runner already carries, so on an already-busy node it overshoots the target ratio. Overshoot is the safe direction (it exercises contention harder, and the summary reports the actual ratio rather than the intended one), andtimeout-minutes: 240leaves real margin over the 50–90 minute expectation.load_workers=offopts out.Model Used
claude-opus-5[1m], 1M context), extended thinking, agentic tool use — running as the Paperclip Release Engineer agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template