Skip to content

hil: make hil_report.md a rendering of hil_report.json - #3840

Merged
hathach merged 4 commits into
masterfrom
hil-report
Aug 25, 2026
Merged

hil: make hil_report.md a rendering of hil_report.json#3840
hathach merged 4 commits into
masterfrom
hil-report

Conversation

@hathach

@hathach hathach commented Aug 21, 2026

Copy link
Copy Markdown
Owner

hil_report.json and hil_report.md were written independently. Four writers produced the markdown and three wrote no JSON at all — and those three are the paths where a run died, so a JSON consumer saw nothing exactly when it mattered. hil_summary.py, which builds the per-board verdicts an agent hands back, reported the whole fleet as "no report row" while a human read the real story from the markdown.

The markdown is now a rendering of the sidecar: every writer goes through render_report(), so a table can never contain something the JSON does not.

What changed

Two new fields. scope — a three-board PR run and a full run that lost 24 boards were indistinguishable. caveat — how the run ended, which the abandon path used to express by prepending text to a file it had not written.

banner vs caveat is load-bearing. banner carries rig health across an --accumulate retry; caveat records how a run ended and must not. Conflating them made a clean retry publish an abandonment that never happened.

One module owns the document. helper/hil_report.py holds the vocabulary, renderer, writers, merge and per-board fold — dissolving the import cycle that forced write_timeout_report to compose its own markdown, and removing a duplicate cell classifier kept in sync by hand. hil_summary.py is deleted; its CLI moves here.

hil_ci.sh uploads the sidecar so a remote --accumulate has a merge base; it wipes REMOTE_DIR every run, so a retry previously started fresh and its one-row table replaced the full-fleet one.

Not pure code motion

Against master, hil_test.py held only render_matrix and accumulate_report. render_report, write_report, mark_report_abandoned, mark_report_no_boards, _load and cell_state are new — where three review rounds found the defects. Each was reproduced before being fixed and is pinned by a test: a board that ate the whole pool guard summarized as a pass; a stale board-locked cell masked a wedge, so a board that hung the rig was published as LOCKED and re-run.

Verification

500 unit tests, pre-commit clean, three /code-review max rounds. On the rig: a 25-board fleet run, then 10 randomized passes through hil_ci.sh mixing fresh and --accumulate, checking after each that the markdown re-renders from the sidecar byte for byte. Zero problems. Espressif is covered by CI's esp jobs.

Copilot AI lite review requested due to automatic review settings August 21, 2026 08:18
hathach added a commit that referenced this pull request Aug 21, 2026
…3840

Three findings the PR deliberately did not close, each with what is already
established, what remains, and why it was split out.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR centralizes HIL reporting around hil_report.json, rendering Markdown from the canonical sidecar while adding accumulation, failure metadata, and unified verdict generation.

Changes:

  • Adds shared report rendering, normalization, merging, and summarization.
  • Updates HIL orchestration, CI sidecar handling, and workflow caveat contracts.
  • Moves the summary CLI and updates tests, documentation, and operator guidance.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 10 comments.

Show a summary per file
File Reviewed change and final comments
test/hil/test/test_hil_report.py Adds report behavior and regression tests.
test/hil/test/test_hil_health.py Updates health-related test coverage.
test/hil/test/test_hil_bounded.py Updates moved report tests and imports.
test/hil/hil_test.py Uses centralized report APIs. Critical (4 votes): Pass the run’s accumulate state to mark_report_no_boards() so accumulated rows are preserved.
test/hil/hil_ci.sh Stages and transfers the JSON merge base. Critical (3 votes): Fetch Markdown and JSON together and commit or discard them as a pair to preserve the rendering invariant.
test/hil/helper/hil_summary.py Removes the superseded summary module.
test/hil/helper/hil_report.py New report owner, renderer, merger, and CLI. Critical (1 vote): Preserve abandonment caveats only for non-fresh accumulation. Critical (1 vote): Ensure recovery sidecars include rows represented by preserved Markdown. Moderate (2 votes): Coerce non-list rows during normalization. Moderate (4 votes): Use normalized data in the CLI. Moderate (3 votes): Safely normalize or render unhashable cell values.
test/hil/helper/hil_health.py Retains process-cleanup functionality.
docs/superpowers/specs/2026-08-21-hil-report-module-design.md Documents the module design. Nit (4 votes): Update the contract/non-goal section to describe the implemented caveat interface.
docs/superpowers/plans/2026-08-21-hil-report-module.md Documents the implementation plan.
docs/superpowers/followup/pr3836-report-single-source.md Removes the completed follow-up.
docs/superpowers/followup/pr3803-hil-blindness-reporting.md Updates report references.
.claude/workflows/test-hil-validate.mjs Tests caveat-aware verdict handling.
.claude/workflows/hil-validate.js Adds caveat-aware workflow verdicts. Critical (4 votes): Carry the latest relevant retry caveat into the result. Critical (1 vote): Require caveat in the response schema.
.claude/agents/hil-operator.md Updates the operator CLI and contract documentation.
Suppressed comments (9)

docs/superpowers/plans/2026-08-21-hil-report-module.md:658

  • The plan's out-of-scope list still says the workflow will not carry caveat, while this PR changes the operator schema/return payload and makes that field gate the workflow verdict. This committed plan is now inconsistent with the implementation; update the stated scope and validation contract.
## Out of scope

Each its own follow-up, unchanged from the spec:

- Splitting `accumulate_report`'s `mret` folding from its merge.
- The flat `HIL_POOL_TIMEOUT` that does not scale with board count.
- Carrying `caveat` through the operator/workflow return contract (`hil-validate.js:34`).

test/hil/helper/hil_report.py:253

  • If a fresh run fails before accumulate_report() creates either artifact (for example, Manager()/Pool() setup fails after the fresh wipe), _load() returns unreadable and _stamp_markdown() immediately returns because hil_report.md is absent. _abandon_exit then leaves no JSON caveat for the CLI/agent to consume, recreating the missing-report failure this change is meant to remove. Create a caveat-only document when both artifacts are missing, while retaining the markdown fallback for an unrecoverable sidecar beside an existing table.
    # Unreadable sidecar, or the document write failed. Either way the markdown is what
    # the PR comment reads, so stamp it directly rather than giving up.
    try:
        _stamp_markdown(report_dir, notice)

test/hil/helper/hil_report.py:6

  • The invariant stated here is not true for all current artifact writers: .github/workflows/build.yml still directly echos hil_report.md for no-affected-board legs (for example lines 473 and 559) and creates no JSON sidecar. Either route those skip artifacts through the same document renderer or explicitly scope this claim to the HIL Python writers; otherwise the advertised JSON/Markdown invariant still has exceptions.
The markdown IS a rendering of the sidecar -- every writer goes through render_report(), so
a table can never contain something the JSON does not. This module owns the whole life of

test/hil/helper/hil_report.py:12

  • This dual-mode explanation says a guarded hil_health import is below, but the module defines its own _p and has no such import. The stale description can send future maintainers looking for an import-cycle workaround that no longer exists; document the local _p implementation instead.
Dual-mode by design: imported as `helper.hil_report` by hil_test.py, and run as a script by
the operator (see .claude/agents/hil-operator.md). A script run puts test/hil/helper on
sys.path rather than test/hil, hence the guarded hil_health import below.

test/hil/hil_test.py:2296

  • The timeout/worker-abort report is passed scope='' even when the invocation was filtered with -b or -bt. A scoped run that dies on the pool guard consequently loses the scope field and is again indistinguishable from a full run that lost boards. Compute the invocation scope before entering the pool and pass it on this path (and to its fallback).
                    hil_report.accumulate_report(mret, report_dir, fresh, '',
                                      health_banner + _blind_note(mret)
                                      + _stray_note(mret), caveat=banner)

test/hil/hil_test.py:2335

  • The worker-abort path has the same scope loss as the timeout path: it hard-codes an empty scope in the document even for a board-filtered invocation. This makes the new scope field absent precisely on another failure path where it is needed; pass the current invocation scope instead.
                    hil_report.accumulate_report(mret, report_dir, fresh, '',
                                      health_banner + _blind_note(mret)
                                      + _stray_note(mret), caveat=banner)

test/hil/test/test_hil_report.py:850

  • This test only scans the entire shell source for a line containing both warning and accumulate; the surrounding comments already satisfy that predicate. Removing the runtime warning would therefore leave the test green. Exercise the accumulate/no-merge-base branch or assert the specific executable warning command instead of searching all source lines.
        warn = [ln for ln in (Path(HIL_DIR) / 'hil_ci.sh').read_text().splitlines()
                if 'warning' in ln.lower() and 'accumulate' in ln.lower()]
        self.assertTrue(warn, 'no warning when --accumulate has no local sidecar')

test/hil/test/test_hil_report.py:1015

  • This test does not exercise the render-failure ordering it claims to pin: _load() converts the seeded numeric duration to None, so accumulate_report() renders successfully and the assertion only checks that both files contain the word abandoned. A regression that writes JSON and then fails while rendering would still pass this test. Force render_report() to fail (or otherwise inject a renderer failure) and assert the committed artifacts remain consistent.
        (rd / hil_report.REPORT_JSON).write_text(json.dumps(
            {'rows': [{'board': 'boardA', 'cells': {'t': 'pass'}, 'duration': 119.0}],
             'banner': '', 'caveat': '', 'scope': ''}))
        hil_report.accumulate_report([('boardB', 0, 0, [('boardB', {'t': 'OK'}, '1s')], 0)],
                                     rd, False, '', '')
        doc = json.loads((rd / hil_report.REPORT_JSON).read_text())
        self.assertEqual((rd / hil_report.REPORT_MD).read_text(),
                         hil_report.render_report(doc) + '\n')

test/hil/test/test_hil_report.py:635

  • The test name and docstring claim to exercise a renderer failure, but the bare 'boardA' row is discarded by _load() before render_report() runs, so this path never raises and the ordering invariant is not tested. Make the renderer actually fail (for example by patching it) and assert the pre-existing JSON/markdown remain consistent after the failure.
        (rd / hil_report.REPORT_JSON).write_text(json.dumps(
            {'rows': ['boardA'], 'banner': '', 'scope': '', 'caveat': ''}))
        hil_report.mark_report_abandoned(rd, 'the worker pool would not shut down.')

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .claude/workflows/hil-validate.js Outdated
})

const { pass, wedged, locked } = summarize(results, args.force)
const { pass, wedged, locked } = summarize(results, args.force, first?.caveat || '')
Comment on lines +35 to +38
// the run-level caveat: abandoned / aborted / selected-no-boards. `banner` carries rig
// HEALTH across an --accumulate retry; `caveat` carries how THIS run ended, and every
// row can still say pass while it failed — so it gates `pass` in summarize() below.
caveat: { type: 'string' },
Comment on lines +52 to +55
**Non-goals (explicit follow-ups, not this change):**
- Splitting `accumulate_report`'s `mret` folding from its merge (see "Deliberate wart").
- The flat `HIL_POOL_TIMEOUT` that does not scale with board count (`hil_test.py:225`).
- Carrying `caveat` through the operator/workflow return contract (`hil-validate.js:34`'s schema).
Comment thread test/hil/helper/hil_report.py Outdated
Comment on lines +267 to +270
if _already_abandoned(doc):
# SKILL.md gives the two notices OPPOSITE rules, and an abandon outranks a
# filter that matched nothing -- do not overwrite the record of a failed run.
return
Comment thread test/hil/helper/hil_report.py Outdated
Comment on lines +69 to +70
for r in raw.get('rows') or []:
if not isinstance(r, dict) or 'board' not in r:
Comment thread test/hil/helper/hil_report.py Outdated
print(f'error: {jpath} not found -- did hil_test.py run in this directory?',
file=sys.stderr)
return 1
json.dump(summarize(cfg, boards, json.loads(jpath.read_text())), sys.stdout, indent=2)
Comment thread test/hil/helper/hil_report.py Outdated
Comment on lines +75 to +76
'cells': {str(k): v for k, v in cells.items()}
if isinstance(cells, dict) else {},
Comment on lines +362 to +366
body = prior if prior.strip() else render_matrix(
[(r['board'], r['cells'], r['duration']) for r in doc['rows']])
report_dir.mkdir(parents=True, exist_ok=True)
(report_dir / REPORT_JSON).write_text(json.dumps(doc, indent=2) + '\n')
(report_dir / REPORT_MD).write_text(head + '\n' + body, encoding='utf-8')
Comment thread test/hil/hil_ci.sh Outdated
Comment on lines +409 to +411
if scp -q "$REMOTE:$REMOTE_DIR/$extra" "$ROOT_DIR/$extra.tmp" 2>/dev/null \
&& [ -f "$ROOT_DIR/$extra.tmp" ]; then
mv -f "$ROOT_DIR/$extra.tmp" "$ROOT_DIR/$extra"
Comment thread test/hil/hil_test.py Outdated
except OSError:
pass
rd = Path(os.environ.get('HIL_REPORT_DIR', '.'))
hil_report.mark_report_no_boards(rd, msg)
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Code-size comparison skipped: PR selection built no families on this push.

@@ -0,0 +1,63 @@
# Two loose ends in the report document

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CLAUDE.md adherence — this follow-up doc bundles two unrelated deferred items into one file. The root CLAUDE.md "Deferred work" rule requires:

…one doc per follow-up, in docs/superpowers/followup/pr<NNN>-<topic>.mdNever bundle unrelated follow-ups into one file.

The two items here are independent — different files, different root causes, no shared fix:

  • Item 1 (L6) — a documentation fix to .claude/skills/hil/SKILL.md (no-boards behaviour drift).
  • Item 2 (L36) — a durability fix to write_report's two non-atomic writes in hil_report.py.

The doc itself frames them as two ("Two loose ends", L1; "These two are a documentation drift and a durability edge", L60-62), and the PR description lists them as separate bullets each deserving "its own PR." The sibling pr3840-mret-board-result.md correctly keeps to a single topic.

Consider splitting into two docs (e.g. pr3840-skill-md-no-boards-drift.md and pr3840-write-report-atomicity.md) so each can land — and be deleted — independently when its own PR merges.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Hardware-in-the-loop (HIL) Test Report

hfp-iar

✅ 54 passed · ❌ 2 failed · ⚪ 0 skipped · blank not run

Board usbtest cdc_msc_throughput audio_test_freertos cdc_dual_ports cdc_msc cdc_msc_freertos dfu dfu_runtime hid_boot_interface hid_generic_inout midi_test msc_dual_lun mtp printer_to_cdc duration
stm32l412nucleo ❌ 0/30 ✅ C 502/509k M 511/511k 80s
lpcxpresso43s67 ✅ 30/30 ✅ C 10.7/9M M 32.7/31.3M 130s
stm32f746disco ✅ 30/30 ✅ C 13.4/12.5M M 24.6/30.7M 109s
stm32f746disco-DMA ✅ 30/30 ✅ C 13.5/12.7M M 26.2/32.6M 85s

hfp.json

✅ 56 passed · ❌ 0 failed · ⚪ 0 skipped · blank not run

Board usbtest cdc_msc_throughput audio_test_freertos cdc_dual_ports cdc_msc cdc_msc_freertos dfu dfu_runtime hid_boot_interface hid_generic_inout midi_test msc_dual_lun mtp printer_to_cdc duration
stm32l412nucleo ✅ 30/30 ✅ C 519/504k M 513/511k 135s
lpcxpresso43s67 ✅ 30/30 ✅ C 12.1/10.7M M 33.6/31M 145s
stm32f746disco ✅ 30/30 ✅ C 13.6/13M M 18.3/30.3M 115s
stm32f746disco-DMA ✅ 30/30 ✅ C 14.8/12.4M M 25/32.1M 88s

tinyusb-esp.json

✅ 20 passed · ❌ 4 failed · ⚪ 0 skipped · blank not run

Board usbtest msc_file_explorer_freertos audio_test_freertos cdc_msc_freertos device_info hid_composite_freertos duration
espressif_p4_function_ev ✅ 30/30 409 KB/s 115s
espressif_p4_function_ev-DMA ✅ 30/30 409 KB/s 116s
espressif_s3_devkitm ✅ 30/30 130s
espressif_s3_devkitm-DMA ✅ 30/30 222s

tinyusb.json

Not all verdicts are evidence. 1 board(s) ran on a worker that went blind on sysfs -- too many bounded reads stranded on a wedged device -- so "not found" from them means "could not tell": stm32h743nucleo. See the usb-kernel-recover skill.

✅ 326 passed · ❌ 55 failed · ⚪ 17 skipped · blank not run

Board usbtest cdc_msc_throughput msc_file_explorer msc_file_explorer_freertos audio_test_freertos cdc_dual_ports cdc_msc cdc_msc_freertos cdc_msc_hid device_info dfu dfu_runtime hid_boot_interface hid_generic_inout host_info_to_device_cdc midi_test msc_dual_lun mtp printer_to_cdc duration
nanoch32v203-fsdev 9s
nanoch32v203-usbfs 8s
frdm_k64f 13s
raspberry_pi_pico_w 1103 KB/s 1022 KB/s 38s
raspberry_pi_pico2 80s
ek_tm4c123gxl ✅ 30/30 ✅ C 507/511k M 514/511k 151s
stm32g0b1nucleo ✅ 30/30 ✅ C 503/503k M 533/569k 174s
metro_m4_express ✅ 30/30 ✅ C 499/510k M 511/511k 205s
lpcxpresso11u37 ✅ 30/30 ✅ C 505/280k M 513/511k 212s
stm32h743nucleo ❌ 0/30 ✅ C 573/525k M 641/628k 188s
stm32h743nucleo-DMA ❌ 0/30 ✅ C 516/517k M 602/549k 67s
ra4m1_ek ❌ 0/30 ✅ C 530/499k M 522/511k 257s
lpcxpresso55s28 ✅ 30/30 ✅ C 8.6/8.1M M 31/28.2M 265s
feather_nrf52840_express ✅ 30/30 ✅ C 499/511k M 511/511k 270s
stm32f407disco ✅ 30/30 ✅ C 611/591k M 656/541k 277s
stm32l476disco ✅ 30/30 ✅ C 533/510k M 511/511k 259s
max32666fthr ✅ 30/30 ✅ C 6.9/13.7M M 20/19.8M 309s
stm32f072disco ✅ 30/30 ✅ C 490/312k M 544/482k 326s
adafruit_fruit_jam ✅ 30/30 ✅ C 507/511k M 516/490k 62 KB/s 62 KB/s 245s
ch32v103r_r1_1v0 ✅ 30/30 ✅ C 507/508k M 516/511k 287s
nrf54lm20dk ✅ C 1.9/4M M 7.3/8.1M 101s
mimxrt1064_evk ✅ 30/30 ✅ C 18.8/12.2M M 30.5/23.7M 1365 KB/s 1344 KB/s 171s
stm32u083nucleo ✅ 30/30 ✅ C 511/498k M 511/516k 240s
ch582m_evt ❌ 0/30 ✅ C 231/204k M 455/457k 181s
raspberry_pi_pico ✅ 30/30 ✅ C 507/523k M 511/511k 443s
ch32v307v_r1_1v0-usbhs ✅ 30/30 ✅ C 7.1/8.1M M 15.9/8.8M 185s
ch32v307v_r1_1v0-usbfs ✅ 30/30 ✅ C 507/509k M 511/511k 109s
stm32f723disco ✅ 30/30 ✅ C 507/513k M 515/528k 13443 KB/s 3912 KB/s 340s
stm32f723disco-DMA ✅ 30/30 ✅ C 507/511k M 511/511k 14563 KB/s 3942 KB/s 138s

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

No memory changes detected across 2495 targets. View Project Dashboard →

hathach added a commit that referenced this pull request Aug 21, 2026
…3840

Three findings the PR deliberately did not close, each with what is already
established, what remains, and why it was split out.
@hathach
hathach force-pushed the hil-report branch 3 times, most recently from 19cf92e to aa4a078 Compare August 21, 2026 15:26
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

hathach added a commit that referenced this pull request Aug 25, 2026
.build.families is read twice in the build-extras block: as a `|`-joined regex for
the metrics artifact pattern, and implicitly as "is anything selected". An EMPTY
list and one REJECTED by the charset guard both leave the regex empty and mean
opposite things, but the branch tested only `-z "$FAMILY_REGEX"` - so every
nothing-selected PR took the fall-open path and DISCARDED the correct all-empty
matrix ci_set_matrix had just produced.

Both merged PRs show it: #3842 (a skill, README.rst and .gitignore) and #3840
(test/hil/** plus docs) each rebuilt all 74 cmake legs after the selector had
correctly chosen no families. The HIL axis was right in both - rig jobs skipped on
#3842, full rig on #3840 per rule 2 - because it never passes through this block,
and no UNSCOPED marker appeared because ci_set_matrix behaved perfectly; the
override happens in the shell afterwards. That combination made it look like the
classifier was at fault when it was not.

Now branches on which case it is: a rejected list falls open exactly as before (all
three values drop together), while an empty list keeps the all-empty matrix so
every leg skips, sets build_filtered=false because nothing was built and there is
nothing to compare a baseline against, and leaves EXAMPLE_MAP at '{}'.

Renames FAM_* to FAMILY_* - FAM meant hw/bsp board family, and the abbreviation
read like it might be something else in a file that also says "family" for the
per-family example map and the get_deps family tokens.

The block had no test at all, which is how this shipped twice. test_ci_metrics now
extracts it from build.yml and runs it for real against all three shapes; the
empty case fails with `129 != 0` against master, and the scoped case with
`'' != 'stm32f4|rp2040'`. The probe quotes its shell assignments with shlex.quote,
so a TMPDIR containing a space cannot fail it for an unrelated reason.
hathach added a commit that referenced this pull request Aug 25, 2026
…3845)

ci: an empty selection must build nothing, plus selector follow-ups

A PR whose build axis legitimately selected nothing rebuilt everything.
build.yml reads .build.families twice - as a |-joined regex, and implicitly
as "is anything selected" - but tested only -z "$FAMILY_REGEX", which an
empty list and a charset-rejected one both satisfy while meaning opposite
things. ci_set_matrix had already returned the correct all-empty matrix;
the fall-open branch discarded it. #3842 and #3840 each spent 74 cmake legs
on it. Branch on the two cases instead, rename FAM_* to FAMILY_*, and cover
the block with a test that extracts it from build.yml and executes it - it
had no test at all, which is how this shipped through two merges.

Follow-ups to the same machinery: glob.escape the repo root at five sites,
so a checkout path containing [ or * stops failing closed; drop the ci-full
label, read after the matrix was already computed and so never functional;
delete 13 mcu:MKL25ZXX / mcu:SAME5X skip tokens matching no board; carry the
rule table in the module docstring, guarded against drift; and pin six
selection behaviours a mutation pass proved untested.

Cut the selector's cost 1.8x (26.0s -> 14.6s) with 0 divergences over 260
paths, and stop scoping the membrowse upload by the PR example filter.
test_the_child_is_reaped_even_when_work_raises asserted that no 'sleep 20'
survives, but pgrep -f searches every process on the host -- another agent
session's retry loop was sleeping 20s between attempts, so the test failed 3/3
in isolation while that ran. It is a pre-commit hook, so it blocked every
commit. A distinctive duration scopes it back to our own child.

Unrelated to the report work.
The markdown IS a rendering of the sidecar now: every writer goes through
render_report(), so a table can never contain something the JSON does not. Four
writers previously composed it independently and three wrote no JSON at all --
and those three are exactly the paths where the run died, so a JSON consumer saw
nothing on the runs that mattered most. The per-board verdicts an agent hands
back reported the whole fleet as "no report row" while a human reading the
markdown saw the real story.

The document gains the two fields the markdown carried but the JSON did not:
`scope` (a three-board PR run and a full run that lost 24 boards were
indistinguishable) and `caveat` (how the run ENDED -- abandoned, aborted, no
boards). `banner` keeps its existing meaning: the rig-health conditions the
cells were collected under. The distinction is load-bearing, because banner
carries across an --accumulate retry and caveat must not; conflating them made a
clean retry publish an abandonment that never happened, and let a stale notice
from an earlier attempt silence a genuinely new one.

Consolidating into helper/hil_report.py is what makes that complete. The
renderer, the writers, the merge and the fold to per-board verdicts live in one
module that hil_test.py and hil_health.py both import. That dissolves the import
cycle which forced write_timeout_report to compose its own markdown -- the
pool-guard fallback renders like everyone else, so all five writers are
byte-identical -- and removes the second copy of the cell classifier, which
hil_summary.py's docstring described as "the EXACT classifier hil_test.py's own
tally uses". Two copies of one rule, kept in sync by hand against re-typed emoji
literals: change REPORT_CELL and the human's table and the agent's verdict
silently disagree.

hil_summary.py is deleted; its CLI moves here and the two harness docs that
invoke it by name follow. `caveat` gates the workflow verdict and is required by
its schema, because on the abandon path every row can legitimately pass while
hil_test.py exits non-zero, and an operator omitting the field would silently
disable the gate.

NOT purely code motion, and worth reading as new code: measured against master,
hil_test.py held only render_matrix and accumulate_report. render_report,
write_report, mark_report_abandoned, mark_report_no_boards, _load, cell_state
and the scope/caveat plumbing are new, and three rounds of review found their
defects there. Each was reproduced before being fixed and is pinned by a test: a
stuck board that already had a row got no pool-timeout cell and summarized as a
pass; a stale board-locked cell masked it, so a board that wedged the rig was
published as LOCKED and re-run; mark_report_abandoned republished the markdown
even when it declined to stamp, inspected the wrong field, gave up on a missing
or torn sidecar, and called the table "partial" against SKILL.md's contract that
it IS this run's; write_report swallowed OSError, making two layers of fallback
dead code, and committed the JSON before rendering; a sidecar with a null banner
or a non-list rows killed a fully successful run with no artifact at all; an
unhashable cell value raised on the normal accumulate path; the no-boards exit
republished a previous run's rows, and its guard blocked even a fresh run.

_load is the trust boundary for all of it, since hil_ci.sh uploads a sidecar as
the --accumulate merge base and it is therefore untrusted input. A corrupt cell
drops rather than being coerced to str, which would classify it as a pass.

Verified on the rig: a 25-board fleet run, ten randomized passes mixing fresh
and --accumulate over different board sets, and every containment path exercised
against the module actually staged there.
hil_ci.sh rm -rf's REMOTE_DIR at the start of every run, and accumulate_report
merges onto the sidecar in the run's cwd -- so a remote retry found nothing,
silently started fresh, and its one-row table REPLACED the full-fleet one it was
meant to extend. The copy-back at the end of the script has always existed; this
is the other half of it.

Verified on the rig end to end, which is the only place this shows up: a fresh
run of stm32f407disco, then a retry spelled `-av`, and the merged sidecar holds
all three rows where it previously held only nanoch32v203's two.

Gated on --accumulate rather than unconditional: a fresh run unlinks the sidecar
anyway. argparse decides whether the flag is present rather than a case arm --
hil_test.py declares `-a, --accumulate`, so `-av`, `-va`, `--accum` and `--acc`
all mean it, and hil-validate.js tells operators to retry "adding -v". A missing
merge base warns loudly instead of failing silently, since the run succeeds
either way and a mistyped retry quietly shrinking the published table is the
actual damage. The upload also checks provenance: hil_report.json is not
namespaced by CONFIG or REMOTE, so a run against another rig leaves a sidecar
behind that would otherwise merge in, publishing boards that never ran here.

The copy-back fetches both halves to temps and commits them as a pair, or
neither. It used to delete both before scps that are allowed to fail silently,
so an ssh drop at the end of a 60-minute run destroyed the report and the next
retry's merge base; copying them independently was no better, since a markdown
that arrived beside a sidecar that did not left the local pair failing the
rendering invariant.

<config>.failed is not uploaded: hil_test.py only ever writes it, never reads
it. The retry spec already reaches the rig as the -b/-bt arguments the caller
expanded from it.
Records why the report code was spread across three modules and what the
consolidation buys, so the next reader does not have to re-derive it from the
diff. Names the new functions explicitly rather than presenting the change as
pure code motion -- that framing points reviewers away from the code that
carried the defects.

Three findings this PR deliberately does not close get one handoff each, per
CLAUDE.md: the worker-result tuple hil_report still unpacks positionally,
SKILL.md's no-boards rule drifting from the code, and write_report's two
non-atomic writes. Drops the pr3836 handoff, which this branch implements.
@hathach
hathach merged commit 375e200 into master Aug 25, 2026
60 of 64 checks passed
@hathach
hathach deleted the hil-report branch August 25, 2026 08:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants