fix(ci): the fleet monitor can see a wedged job — and "could not evaluate" is no longer green (#429) - #432
Conversation
…uate" is no longer green (#429) A job stuck in_progress holds its runner and looks exactly like a slow one. On 2026-09-16 `Kani (relay-mix-quad)` wedged three times (52, 96, 105 min against 2m48s–3m21s completions), each found by a human reading durations. fleet-status.yml watched for QUEUED runs, a shape a wedge never has. scripts/ci-wedge-watch.rs flags a job running past 5x the median of its own recent successful runs (floor 10 min, >= 3 samples, otherwise "no baseline"). Exit 0 / 1 / 2 = clear / wedged / could not evaluate. It reports; it cancels nothing. A new hosted `wedge` job runs it every 15 minutes and keeps one rolling issue, naming each wedged job once. Three traps, each measured before it was designed around: - run status is not job status: a matrix run with queued legs reports `queued` while a job runs, so jobs come from queued AND in_progress runs; - the last 5 COMPLETED Kani runs held one successful relay-mix-quad leg (cancel-in-progress), below the sample minimum, which would have made the #429 wedge itself "no baseline"; history comes from SUCCESSFUL runs; - `shell: bash` runs with -e, so reading $? after a bare call that exits 1 loses the code and turns a wedge into UNKNOWN. Found by executing the extracted blocks under GitHub's flags, with a control; fixed with || rc=$?. The starvation step's `|| true` swallowed API failures into an empty queue. Both checks now report UNKNOWN and fail the monitor's own run. SWREQ-RELAY-FLEET-P01 + FV-RELAY-FLEET-001, both `implemented`. Refs #429 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
|
A limitation of the hosted placement, measured while this PR waited. At 19:24Z, #430's hosted The new I'm keeping the placement anyway. The alternative is a self-hosted runner, which would put the monitor back on the fleet it watches (or on 🤖 Generated with Claude Code |
…output without jq Reverses this PR's own placement decision on measurement. On 2026-09-16 relay's hosted jobs waited 60–120 min for a runner (hosted concurrency is shared across the org; Verus and Rocq created 18:55 started 20:53), so a hosted wedge alarm could arrive about as late as the human it replaces. The `status` job already runs on `light`, which is three boxes (ci-01-5/-8/-10), not the single box an earlier comment claimed. `jq` is not known to be installed on `light`, so the detector now writes the job-summary markdown (--summary-md) and the alarm rows (--alarm-tsv, `<job url>\t<row>`) itself; the workflow uses no jq. New test covers both renderers. The FV step pinning the `|| rc=$?` fix was repointed at the new line; checked that it fails against a copy without the fix. Refs #429 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
|
Reversing my earlier comment: the wedge job now runs on Earlier I wrote that saturation would only make a hosted job "late" and kept the placement. The same evening measured how late: relay's hosted jobs waited 60–120 minutes for a runner.
At that delay, a wedge alarm arrives about as late as the human it was meant to replace. Commit 942f794:
🤖 Generated with Claude Code |
…ured ones `a_leg_running_normally_is_not_flagged` was named for Kani (relay-notch) but held made-up ~2-minute durations. Its real successful runs on 2026-09-16 took 888, 816 and 656 s (median 13.6 min). That surfaced live: with #430's relay-notch leg at 18.8 min I suspected a wedge from a remembered two-minute sighting; the detector, reading the history, did not flag it, and was right (threshold 68 min). The test now uses the measured durations and pins the per-job threshold. Refs #429 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
…not a 6-hour hold (#429) The wedge detector's first live catch: `Kani (relay-mix-quad)` on ci-01-10 at 125.4 min against a 4.0-min median, the only check holding #424. Its fourth hang, all inside the Kani step, on three different runners — a per-leg intermittent hang, not one bad box. kani.yml set no `timeout-minutes`, so every hang held its runner for GitHub's 360-min default while the PR looked merely slow. 60 min is 2.4x the slowest of 180 measured successful leg runs (relay-notch, 25.1 min). A timed-out leg concludes `cancelled`, and the `kani-gate` aggregate fails closed on anything but success/skipped, so the hang turns the required gate red instead of hiding. FV-RELAY-FLEET-001 gains a step pinning the cap. Refs #429 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
…d call a release ready it had not read (#433) Found while fixing the same shell trap in #432. #423's nightly job has not produced a report once: `shell: bash` runs with -e, so the script's exit 1 ("not ready", the normal state) ended the step before `READY=$?` ran. Its first scheduled run failed with `Process completed with exit code 1` and an empty summary, while the workflow's own comment promised never to fail on "not ready". Reviewing it for that turned up four ways the tool could issue a verdict it had not earned, each now shown with a control against #423's code: - an unparseable artifact file was warned about and skipped. With the release's only blocker in a broken file it printed "1/1 artifacts done (100%)" and exited 0: READY; - no visible tags (failed git, shallow clone) defaulted to an empty set and re-targeted the oldest scope in the tree; - the next release was picked in string order: falcon-v1.100.0 before falcon-v1.99.1; - any error exited 1, identical to "not ready". Unreadable directory entries were skipped the same way. Exit codes are now 0 ready / 1 not ready / 2 could not evaluate, and the workflow captures the code with `|| READY=$?` and fails only on 2+, writing an UNKNOWN summary line. `run_at(root, ...)` lets the tests build fixture repos; three mutants each fail exactly their matching test. Backfills the requirement #423 shipped without: SWREQ-RELAY-READY-P01 + FV-RELAY-READY-001, both `implemented`. Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…d never measured starvation (#436) (#437) * fix(ci): the fleet monitor installs gh instead of assuming it — it had never measured starvation (#436) The `light` runners do not ship `gh`. The starvation step ran `gh api ... 2>/dev/null || true` for months, so `gh: command not found` became an empty result and "0 queued". It printed `queued-too-long: 0` at 22:23Z on 2026-09-16 while #424's required Verification gate had been queued for 60 minutes (107 in total). #432 removed the mask, and the first dispatched run on main (35176122362) correctly failed both jobs as UNKNOWN. Both jobs now use ./.github/actions/setup-gh: gh v2.100.0 (two weeks old; the two-day-old v2.101.0 passed over on purpose), SHA-256 verified against the release's checksums file and a local download, installed user-space into $RUNNER_TEMP. A wrong checksum aborts before extraction (checked locally). FV-RELAY-FLEET-001: records the vacuous history and the observed failure, pins the install with three steps, and states what is still not claimed — the */15 schedule actually ran every 2.4–5.5 h, so unattended latency is hours (#436). Refs #436, #429 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(ci): the verification gate installs gh too — no PR has carried its summary comment since v1.55 (#153) Same missing binary as #436, second consumer. The "Post sticky PR comment" step is masked (continue-on-error, cosmetic by design) and its log on #433 reads: `skipping PR comment — 'gh' not on PATH (self-hosted runner without gh?)`. #433 has zero bot comments. The verdict was never affected; the summary simply never reached a PR. The step now runs ./.github/actions/setup-gh first (same condition). This PR's own Verification gate run is the proof: the summary comment should appear on it. #153's other half (Node 20) is already handled — FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 is set in the gate's environment. Refs #153, #436 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… fleet monitor, readiness report (#439) * verify(batt): promote FV-FALCON-BATT-003 — the absent-battery evidence ran in the merge gate (#413) Code-free promotion under the two-commit rule. The implementation landed in #430 (a1b0e17) at `implemented`. Evidence, observed rather than asserted: - #430's REQUIRED Verification gate (run 35137919049, the run that gated the merge) matched FV-FALCON-BATT-003 and executed all four of its steps: PASS — `cargo test -p falcon-core --release absent_battery_blocks_arming` (4.27 s) and the three source greps (Option<f32> defaults to None for read_battery_v and BatteryDriver::voltage; arming requires batt_present). - Re-run on main a1b0e17: all four steps exit 0, and the test really runs (`1 passed`, not a vacuous zero-match filter). - #430 merged with all 8 required contexts SUCCESS. Scope: this promotes the VERIFICATION artifact. The requirement it verifies, SWREQ-FALCON-ORPHAN-P01, is broader (wiring the orphaned crates) and stays `proposed`. #413 stays open until a tagged release carries the fix. Refs #413 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * verify(vgate): promote FV-RELAY-VGATE-004 — the required Format gate can fail, and on main it passes for real (#409) Code-free promotion. Implementation merged in #424 (4c5feda). Evidence, observed: - The flag is gone: ci.yml has no continue-on-error on `cargo fmt --all -- --check` (the artifact's first step). - main bcbe93e's Format job (CI run 35185137151): step `success`, 0 `Diff in`, no `Process completed with exit code` line — a real pass, not a masked failure (the 3ec4a2f log before #424 had 893 diffs and exit code 1 under a `success` job). - #424's own gate did NOT execute this artifact (default `falcon` filter, and it carries no `falcon` tag), so this PR's Verify-Filter includes it: its two steps run in this PR's gate. Scope: this verifies the Format-gate instance only. SWREQ-RELAY-VGATE-P04 stays `proposed` (its v1.139 scope is recording dark tracks). Refs #409 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * verify(fleet): promote FLEET-P01 — the monitor evaluates on the light runners it failed on for months (#429, #436) Code-free promotion. Implementation merged in #432 (7daf818) and #437 (bcbe93e). Evidence, observed: - FV-RELAY-FLEET-001's steps executed in #437's merge gate (run 35182187826): all PASS, including `rust-script --test scripts/ci-wedge-watch.rs` and the setup-gh pins. - A run that EVALUATES on main (the artifact's own promotion condition): fleet-status dispatched on bcbe93e, run 35185164979, both jobs on ci-01-8 — `gh-2.100.0.tar.gz: OK`; starvation query succeeded (its "could not be evaluated" step skipped); wedge detector `10 job(s) running, 0 wedged`. ci-01-8 is the box whose scheduled runs printed a vacuous `queued-too-long: 0` before #437. - The detector's first live catch (2026-09-17 00:53Z): Kani (relay-mix-quad) 125.4 min vs 4.0 min median, flagged; recorded on #429. Not claimed (unchanged in the artifact): detection within 15 minutes — scheduled runs are throttled to every few hours (#436, CIFLOW-P01). Refs #429, #436 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * verify(readiness): promote READY-P01 — on a not-ready tree the nightly job is green and publishes the report Code-free promotion. Implementation merged in #433 (572ae38). Evidence, observed: - FV-RELAY-READY-001's steps executed in #433's merge gate (run 35176152604): all PASS, including the fixture-repo tests of the four verdict bugs. - The artifact's promotion condition — a run on main, GREEN, with a non-empty summary: Release readiness dispatched on 572ae38 (run 35182551834), `Report` step success, printing "## Release readiness — falcon-v1.139.0 / 0 of 15 artifacts done" and the notice "next release still has blocking artifacts". That is exactly the falsification case: the 2026-09-16 scheduled run under the old code failed with exit code 1 and an empty summary on a not-ready tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…e hold breaks `shell: bash` runs with -e, so the rung that FAILS kills the step before it prints the rung, the reason, or the longest-holding number the job exists to publish. Both soak runs on main (2026-09-17 08:41 and 21:08) stopped right after the 40 s rung with `Process completed with exit code 1` and no ::notice::. The ratchet this job is built around — "a published number that may not get worse" — has therefore never published one on a breaking run. Same trap as the wedge detector's (#432): capture the code with `|| RC=$?` instead of letting -e end the step. The break message now also says WHICH failure it was — timeout (rc 124), a verdict FAIL, or a bench that exited without a verdict — so a crash is never read as a hold that merely broke. Measured with this fix on the #452/#434 branch: the ladder ran 25, 40, 60, 90, 120 and 300 s, all PASS, and printed "longest holding duration 300s". On main it dies at the 60 s rung. Refs #403 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
…ht, and the analytic plant can tilt (#434, #452, #435) (#456) * fix(core): the accelerometer is not a gravity reference in flight — hold converges at 5 Hz aiding (#434, #452) Two faults, each measured on its own discriminating run. THE HARNESS (#452). SimBackend::read_imu returned the gravity reaction whatever the motors did, so tilt could be read off the accelerometer exactly and dead-reckoning between fixes was free. It now carries the non-gravitational acceleration its own physics produced — thrust, wind, drag, and the ground reaction while in contact — and rotates that into the body frame: exact for this Euler plant, taken after the ground clamp, no finite differencing. THE ESTIMATOR (#434). EstimatorPartition::step fused the accelerometer as gravity every tick. A multirotor's accelerometer senses specific force along the thrust axis and shows almost none of the vehicle's tilt, so the tilt estimate was pulled level exactly while the vehicle tilted to accelerate; with a realistic accelerometer the hold diverged at every fix rate, including a fix on every tick. The update now runs only where the reading really is gravity: on the ground, or when no fix has been accepted for about a second. FlightCore::step supplies "flying" from the cascade's own previous collective, so a standalone estimator and pre-arm on the ground are unchanged. MEASURED: #434's 5 Hz case (1 m off, gz tuning) holds within 0.5 m at 60 s at both 500 Hz and 100 Hz loop rates, against 75 m at 20 s and 3.4 km at 60 s before. Restoring the unconditional update fails that test at 5134 m. A 15 s GNSS outage stays within 5 m. Three harnesses were idealized in the same direction and were fixed with it, because no vehicle conveys attitude through the accelerometer alone: TiltBackend and TumbleBackend now ROTATE at a fixed rate and report that rate, with the accelerometer reading the thrust axis. Both failsafe campaigns and the runaway point test pass on them. #404's altitude anti-windup gate also depended on the old estimator: it read a vehicle stopped 2.15 m short under thrust lapse as "still converging" on a 0.0005 m/s residual, and only charged before because the vertical-velocity estimate lagged and dithered in sign. It now also requires |v_z| > 0.05 m/s. optimistic_variance_diverges_under_noisy_gps no longer holds: over-trust peaks at 0.40 m, not the ~2400 m recorded at v1.19. The test is renamed to what it measures and FV-FALCON-GNSS-001 drops to implemented for re-verification. Golden log and f100 fixture regenerated deliberately (LOG-P03) via their own ignored regen tests. SWREQ-FALCON-HOLD-P01 + FV-FALCON-HOLD-001 (v1.140, implemented): the requirement's acceptance is on the gz plant, and every number here is SimBackend, so the gz run is what promotes it. Refs #434, #452, #403 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * feat(gz): the hold verdict measures horizontal drift, not just altitude (#403) The flightcore verdict's final_dist is the ALTITUDE error alone, so every gz hold verdict to date reported horizontal drift as zero by construction — the same shape as a gate that passes having checked less than it claims. SWREQ-FALCON-HOLD-P01 names closing this measurement gap as part of the requirement, not a follow-up to it. The verdict now also carries final_horiz, peak_horiz and rms_horiz_steady, and PASS requires final_horiz < 1.0 m and peak_horiz < 2.0 m alongside the existing altitude terms — HOLD-P01's 1.0 m horizontal, 0.5 m vertical. Measured after the change: mock flightcore at 25 s still PASSes (horizontal 0.00 m — MockPhysics applies no torque, #435), and the gz leg on this branch held 2.121 m against a 2.00 m command with n=0.013 e=-0.038 m at 25 s. Refs #403, #434 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(soak): the endurance ladder loses its measurement exactly when the hold breaks `shell: bash` runs with -e, so the rung that FAILS kills the step before it prints the rung, the reason, or the longest-holding number the job exists to publish. Both soak runs on main (2026-09-17 08:41 and 21:08) stopped right after the 40 s rung with `Process completed with exit code 1` and no ::notice::. The ratchet this job is built around — "a published number that may not get worse" — has therefore never published one on a breaking run. Same trap as the wedge detector's (#432): capture the code with `|| RC=$?` instead of letting -e end the step. The break message now also says WHICH failure it was — timeout (rc 124), a verdict FAIL, or a bench that exited without a verdict — so a crash is never read as a hold that merely broke. Measured with this fix on the #452/#434 branch: the ladder ran 25, 40, 60, 90, 120 and 300 s, all PASS, and printed "longest holding duration 300s". On main it dies at the 60 s rung. Refs #403 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * docs(hold): the gz ladder confirms it — 40 s on main, 300 s with the fix (#403, #434) The Soak workflow's gz ladder at the default 5 Hz aiding, each rung on a fresh server: main (35276573490): 25 s PASS (0.01 m) · 40 s PASS (0.24 m, degrading) 60 s FAIL — alt err 1.46 m, rms 1.87 m, est_z -0.53 m longest holding duration 40 s fix (35274954189): 25/40/60/90/120/300 s all PASS alt err 0.00 m from 60 s on, horizontal peak <= 0.04 m longest holding duration 300 s (the dispatched ceiling) Main's vehicle does not drift away, it SAGS: at the 60 s rung it is at half a metre while commanded to 2 m — the altitude half of #403 following the horizontal loss. Every branch rung is measured against the STRICTER verdict from this branch (horizontal now checked, HOLD-P01's 1.0 m); main's rungs are the altitude-only verdict, so main's 40 s is the weaker claim. FV-FALCON-HOLD-001 and SWREQ-FALCON-HOLD-P01 carry the numbers. Both stay `implemented`: promotion is the code-free PR that follows, per the two-commit rule. Refs #403, #434, #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(bench): the analytic plant can tilt, and something disturbs it (#435) MockPhysics::step summed the motor commands into a collective and applied NO moment — "the mixer's torque is approximated as zero in this scaffold" — so the vehicle could not tilt whatever the attitude loop commanded, could not be pushed off its position, and could not be corrected back. Every closed-loop attitude or horizontal result on the analytic tier was vacuous, including ENDURANCE-P01's 12 h hold. It now applies the verified mixer's own allocation (relay_mix_quad::motors_to_torque_signs scaled by 0.25, matching falcon-core's SimBackend so both analytic plants respond to a motor command alike). THAT ALONE CHANGED NOTHING, which is the more interesting half: the scenario starts level at the setpoint on a perfectly symmetric plant, so the horizontal loop is never excited and the excursion stays 0.000 m at any duration. The bench gained the IMU_NOISE knob #435 used locally and never committed. gz bakes noise into its own sensors (a no-op there), and the default 0.0 leaves every existing scenario byte-identical. Measured (mock, gyro sigma 0.01, 250 Hz, 5 Hz aiding), horizontal peak / final / altitude: 120 s 0.24 / 0.11 / 0.01 m 1 h 0.37 / 0.13 / 0.01 m 12 h 0.46 / 0.16 / 0.01 m Every one of those was 0.000 m horizontal before. And the tier now catches what it used to hide: the same plant against the pre-#452 estimator gives 1746 m at 120 s and 124 km at 1 h, with the altitude estimate at 5e22 m — the fault the gz plant showed as a 40 s ceiling (#403/#434). SWREQ-FALCON-ENDURANCE-P01 -> implemented, with the numbers. Refs #435, #403, #434 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(bench): the wasm equivalence crate needs the mixer too (#435) tests/cascade-sitl-wasm INCLUDES examples/falcon-sitl-gz/src/physics.rs by path — deliberately, so the two cannot drift — and that file now applies the verified mixer's torque allocation. The crate is its own workspace, so `cargo build --workspace` at the root never touches it and the break only surfaced in the gz job: error[E0433]: cannot find module or crate `relay_mix_quad` in this scope Adding the same path dependency the example carries. Verified by building tests/cascade-sitl-wasm directly, which is the only thing that does. Refs #435 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(soak): run the analytic tier with the noise its claim is measured at (review of #456) TWO REVIEW FINDINGS, both correct. 1. The nightly analytic tier ran with IMU_NOISE unset, i.e. 0.0 — and this branch's own comment says that at 0.0 the plant is perfectly symmetric, starts level at the setpoint, and reports a hold of 0.000 m by construction at any duration. So ENDURANCE-P01 was flipped to `implemented` on a table measured at sigma 0.01 that the job named as its guardrail could not produce, and the new horizontal PASS bars would have been met vacuously. The step now sets IMU_NOISE: '0.01', the sigma the artifact's table uses; at it the 12 h rung peaks at 0.46 m against a 1.0 m bar. 2. `libm_fabsf_core` re-implemented |x| next to `relay_math::fabsf`, which this same file already uses (line 2230) — two implementations, one bypassing the qualification wrapper. Removed; the anti-windup gate calls relay_math::fabsf. Also adds FV-FALCON-ENDURANCE-001 (implemented): the requirement had no verification artifact, and its steps pin the torque, the knob and the job's noise setting. falcon-core: 63 tests pass. rivet validate PASS. Refs #435 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(campaign): a rotor-out quad must not CLIMB — the deck tolerated 62 m (#398) The full-loop motor-out campaign reported 200/200 with `least_descent` at −62.01 m: a trial that GAINED 62 metres of altitude after losing a rotor, and nothing looked. The struct field said "must stay > 0" while the gate a hundred lines below said altitude is deliberately not asserted — the two contradicted each other, so neither held. The depth of the descent stays unasserted, for the stated reason (a rotor-out quad on this plant is near neutrally buoyant, and bringing it down is the supervisor's LAND job). A CLIMB is different: a vehicle that has just lost a quarter of its thrust authority must not gain altitude. The bound is deliberately weak — 1 m — so it catches a runaway without pretending to gate the profile. Measured on this deck, 200 dispersed trials: pre-#452/#434 estimator: 4 failures — trials CLIMBED 62.01, 47.43, 29.90 m; least descent −62.01 m; worst detect latency 5 steps with the fix: 0 failures; least descent +1.75 m; worst detect latency 2 steps So the bound bites exactly where the estimator was wrong, and the fixed stack passes it. Refs #398, #434, #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(failsafe): a pack that stops REPORTING in flight must raise the failsafe (#413's in-flight half) Found by the v1.139 candidate review (#459). #413 narrowed the reader to Option<f32> so a missing battery cannot masquerade as a healthy one, and that closed the PRE-ARM half: the gate refuses to arm without a pack. In flight nothing consulted it. On None the supervisor recorded batt_present = false and did nothing else; batt_present is read only by update_preflight, and the failsafe evaluates batt_state.low || batt_state.critical — latches that advance only when the estimator is UPDATED. So a vehicle that armed healthy and then lost its ADC, harness or driver kept a frozen last-known-healthy snapshot, and no low-battery failsafe could fire while the pack drained to depletion. Reproduced first: armed, took off, reached Loiter, stopped reporting voltage — after 4 s the mode was still Loiter. The supervisor now accumulates batt_missing_s and the failsafe term is `low || critical || silence > 1 s`: longer than any sane sense interval, far shorter than the reserve an RTL needs. The sustained-excursion debounce on low/critical is untouched, so neither a throttle-punch sag nor a single dropped sample can false-trigger. SimBackend gained `battery_lost`, because a sense that FAILS mid-flight is a different case from one absent at boot and the harness could not express it. SWREQ-FALCON-BATTERY-P03 + FV-FALCON-BATTERY-003 (v1.140, implemented). falcon-core: 64 tests pass. Refs #413, #459 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG * fix(fleet): starvation is a queued JOB, not a queued run (#449) Pulled into v1.140 by maintainer decision, 2026-09-18, after the third false alarm. The check measured RUN status. A matrix run stays `queued` while its legs are served one after another, so it cried starvation three times out of three: #447 and #455 were wholly false, and in #462 the CI run was reported "queued 54 min" while its first job had started four seconds after creation. It now flags a JOB queued past its threshold when nothing else in that run has started since. Hosted runners get their own threshold (120 min against 30) because org-shared hosted concurrency was measured at 60-120 min: #462's OTHER leg really did wait 67 minutes, on ubuntu-latest, which is contention, not a dead fleet — and the alarm text blamed the self-hosted fleet for it. The message now names the runner labels and which kind it is, because the two need different operator actions. REPLAYED against all three alarms with a stub gh, under GitHub's own `shell: bash` flags: #462's CI run not flagged, #462's hosted Gazebo run not flagged, a self-hosted leg queued 45 min with nothing started FLAGGED. Clean against the live API too. EXECUTION FOUND A BUG READING WOULD NOT HAVE. The first draft emitted an empty field for a job with no started_at. Tab is an IFS *whitespace* character, so consecutive tabs collapse: every later field shifted by one, the parsed "timestamp" was a job name, age came out -1, and every case was skipped — the check would have reported a healthy fleet forever. The absent marker is `-` now. FV-RELAY-FLEET-002 (v1.140, implemented) carries the replay. Refs #449, #462, #447, #455 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Verify-Filter: (has-tag "fleet")
Refs #429.
What was wrong
A CI job stuck
in_progressholds its runner and blocks its PR while looking exactly like a slow job. On 2026-09-16Kani (relay-mix-quad)wedged three times: 52, 96 and 105 minutes, against normal completions of 2m48s–3m21s. The same leg finished normally four times that day, twice while a wedged instance was still running. A human found each one by reading durations.fleet-status.ymlcould not see this. It watches for runs queued too long (starvation), and a wedged job has a runner and reports itselfin_progress.The change
scripts/ci-wedge-watch.rs(rust-script). Flags a job running past 5× the median of its own recent successful runs, with a 10-minute floor. With fewer than 3 samples it reports "no baseline" and does not guess. Exit codes are0clear,1wedged,2could not evaluate. It only reports; it cancels nothing.wedgejob, onlight, alongsidestatus. The detector writes the summary markdown and the alarm rows itself, so the job needs nojq. It keeps one rolling issue and names each wedged job once, not every 15 minutes. (The first draft ran on hosted runners. That was reversed on measurement; see the comments.)|| true→ UNKNOWN). Both checks now fail the monitor's own run when they cannot evaluate.Three traps, each measured before designing around it
queuedwhileFormathad alreadycompletedshell: bashruns with-ebash --noprofile --norc -eo pipefail: the old form with detector exit 1 ended the step with nothing recorded (control)The third trap was a bug in this PR's first draft. Running the blocks with plain
bashhad hidden it.Evidence
rust-script --test scripts/ci-wedge-watch.rs: 7 passed. These tests replay CI jobs wedge in_progress for 30x their normal duration, and the fleet monitor cannot see it (watches queued, not stuck) #429's durations: not flagged at 14 min, flagged at 15 and at 105. A mutant (elapsed_s > threshold_s * 10) fails 2 of them.pulseengine/loom(21 jobs running; relay had 0, so a relay-only run would prove nothing):gh:actionlintis clean, apart from the pre-existing customlightrunner-label notice. shellcheck is not installed locally, which is why the blocks were executed rather than trusted.rivet validatepasses.Verify-Filter: (has-tag "fleet")matches exactly 1 artifact (FV-RELAY-FLEET-001), and all 5 of its steps pass locally.Not claimed: that the scheduled workflow has run on GitHub. That becomes observable after merge, and it is the evidence for promoting FLEET-P01 to
verifiedin a separate change.Falsification: this is wrong if a job stuck
in_progresspast 5× its median (with ≥3 successful runs of history) produces no wedge report, or if the monitor reports green on a run where its API queries failed.🤖 Generated with Claude Code
https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG