Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
rm -f "$SELECT_FILE"
if [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then
git fetch --no-tags origin master || true
# the selector reads each roster board's flasher from agentrc's board registry
python3 tools/get_deps.py lib/agentrc
# the suites gate the selector: test_change_impact.py owns the rules,
# test_build_select.py the check_build contract, and test_ci_metrics.py the
# config2 sentinel contract this job rewrites
Expand Down
3 changes: 3 additions & 0 deletions .claude/skills/build-doc/scripts/gen_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

ROOT = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(ROOT / 'tools'))
sys.path.insert(0, str(ROOT / 'test' / 'hil'))
from get_deps import gen_doc as gen_deps_doc, rst_table # noqa: E402
from helper import hil_util # noqa: E402

HEADER = '<!-- Generated by .claude/skills/build-doc/scripts/gen_doc.py - do not edit. -->'

Expand Down Expand Up @@ -109,6 +111,7 @@ def hil_md(root):
out = [HEADER, '']
for title, src in (('ci rig', 'test/hil/tinyusb.json'), ('hfp rig', 'test/hil/hfp.json')):
entries = json.loads((root / src).read_text(encoding='utf-8')).get('boards', [])
entries = [hil_util.hil_boards().resolve(b) for b in entries] # each flasher is agentrc's
if not entries:
continue
out += [f'### {title}', '', f'{len(entries)} boards, from `{src}`.', '',
Expand Down
13 changes: 7 additions & 6 deletions .claude/skills/hil-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ htpc, a dev PC, also carries TinyUF2's rig, `test/hil/local.json` in that checko
`tusb` (ssh alias `hifiphile`) is an external rig (hosted by maintainer hifiphile), exercised by the
GitHub CI `hil-tinyusb (hfp.json)` matrix job — **never run HIL against it unless the user explicitly asks.**

A roster lists `boards` and parked `boards-skip`. Common entry fields: `name` (also the board's lock name), `uid`, `tests`, `flasher {name, uid, args, vid_pid, verify}` (only `name` and `uid` required), and optional `flasher_recover`, `variant`, `logger`, `toolchain`, `uart_uid`, `note`; `test/hil/hil_test.py`'s `Board` type is the full shape. A review may add or enable a `local.json` entry on a dev PC: name, board uid, flasher and tests, and a flashable example to run on it.
A roster lists `boards` and parked `boards-skip`. Common entry fields: `name` (the board's lock name unless the entry has `probe`: then the lock is agentrc's registry `name` for that probe, `hil_util.lock_name`), `uid` (the serial TinyUSB firmware reports), `probe`, `tests`, and optional `variant`, `logger`, `toolchain`, `uart_uid`, `note`; `test/hil/hil_test.py`'s `Board` type is the full shape. A board's `flasher {name, uid, args, vid_pid, verify}` and optional `flasher_recover` are agentrc's, from its board registry (`lib/agentrc/skills/hil/boards.json`, `hil` Board registry) by `probe`, the serial of the debug probe the board is wired to, never the roster's: a rig board's wiring changes by an agentrc commit and a `lib/agentrc` pin bump. Every reader of a flasher resolves the entry through `hil_util.hil_boards()`. A review may add or enable a `local.json` entry on a dev PC: name, board uid, tests, a flashable example to run on it, and `probe` when the registry holds the board's probe, else the board's own `flasher`.

If `local.json` is missing on a dev PC, ask the user to supply one before a `hil_test.py` run or a farm check. An agent that cannot ask (`hil-operator`) does not run `hil_test.py`: it returns the no-run rows Results defines, `detail` naming the missing `test/hil/local.json`. Fall back to `tinyusb.json` only when the request or task scope says so; a manual session locks by board name as Reservation says.

Expand Down Expand Up @@ -186,20 +186,21 @@ A local `hil_test.py` run has no done record; wait for its completion notificati

## Reservation

The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL as part of CI, never stopped (`hil` Board locks). Each board's lock is taken through `test/hil/helper/hil_lock.py`, under the board's roster `name`. The harness's shared scripts (the lock mechanics, the bounded sysfs reads and bus scan of `test/hil/helper/usb_observe.py`, the RTT console, and the `hil` skill's Flashers: the bounded command runner behind `test/hil/helper/hil_util.py` and the per-flasher commands and pyOCD launcher behind `test/hil/hil_flash.py`) are loaded by `test/hil/helper/agentrc.py` through agentrc's `hil_load` (one module per file per process) from `lib/agentrc` at the commit `tools/get_deps.py` pins, fetched by `python3 tools/get_deps.py lib/agentrc`; missing, off that commit or lacking `hil_load.py`, a script it loads or a file in that script's `FILES`, every HIL entry point refuses before any board access.
The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL as part of CI, never stopped (`hil` Board locks). Each board's lock is taken through `test/hil/helper/hil_lock.py`, under its lock name: agentrc's registry `name` for a board with `probe`, whatever this roster calls it, else its roster `name` (`hil_util.lock_name`). `python3 lib/agentrc/skills/hil/scripts/hil_boards.py lock <config> <board>` prints it; the helper's named `hold`, `release` and `--take-over` take lock names, never roster names. The harness's shared scripts (the lock mechanics, the bounded sysfs reads and bus scan of `test/hil/helper/usb_observe.py`, the RTT console, and the `hil` skill's Flashers: the bounded command runner behind `test/hil/helper/hil_util.py` and the per-flasher commands and pyOCD launcher behind `test/hil/hil_flash.py`) are loaded by `test/hil/helper/agentrc.py` through agentrc's `hil_load` (one module per file per process) from `lib/agentrc` at the commit `tools/get_deps.py` pins, fetched by `python3 tools/get_deps.py lib/agentrc`; missing, off that commit or lacking `hil_load.py`, a script it loads or a file in that script's `FILES`, every HIL entry point refuses before any board access.

The provider's `openocd_esp` flasher (`hil` Flashers) is supported by `hil_test.py` and the farm check; `run_case.py` refuses it, its docstring says why. It is unrostered on ci.lan and refused under a CI job (`RUNNER_TRACKING_ID` or `GITHUB_ACTIONS` set), whose end kills the job's processes, until the openocd server and the cleanup holder are shown to survive it. Both take an `openocd_esp` board's lock protected, its group the board alone. Whatever an `openocd_esp` operation leaves not proven ended stops the board, with no retry, park or release: its lock passes to a cleanup holder through agentrc's `hil_lock.hand_to_cleanup_holder`, named with the receipt and the `release --token` it returned in `hil_test.py`'s `cleanup-pending` cell or the farm row's cleanup; a hand-over refused or failed leaves the protected record as the recovery blocker, which they name instead.

Before the flash, `test/hil/hil_flash.py` takes the build's segments and the chip's security state from agentrc (`segments_from_flash_args`, `read_security`), the security read's esptool child run through `run_tracked`: no openocd session starts unless that child is proven ended (`done`), and one `pending`, or `unknown` with its receipt, stops the board as above, its receipt named `esptool child, record <record>`. `read_security` runs the esptool package of the interpreter of the `esptool` on PATH, whatever its version, which its `source` records; the bench ran 5.2.0 and 5.3.1. Another version goes in a versioned venv first on PATH (`python3 -m venv ~/tmp/esptool-5.3.1-venv && ~/tmp/esptool-5.3.1-venv/bin/pip install esptool==5.3.1 'bitstring<5'`, then `PATH=~/tmp/esptool-5.3.1-venv/bin:$PATH` for the run), never over a host's own esptool. The `esptool` flasher's `write_flash` runs the same esptool, and 5.3.1 accepts the underscore spellings it passes (`write_flash`, `--before=default_reset`, a build's `--flash_mode`) with deprecation warnings.

- `hil_test.py` self-locks each board for its flash+test (holder reason `hil_test.py`). A locked board fails immediately (`<board> Failed: board locked: {holder info}`) without flashing — in CI, re-run the failed job later. A board refused by a recovery blocker (`hil` Board locks) fails the same way but gets a `recovery-blocker` cell naming the holder instead of `board-locked`, and is returned as a blocker, never re-run.
- For usbtest bring-up or case diagnosis, load the `usbtest` skill before a manual case run; its `run_case.py` takes and releases the lock itself. This contract still supplies the lock helper, the lock names and rig selection.
- For hardware work outside `hil_test.py` (JLink/GDB, manual flashing, `usbtest.py`, serial poking), hold the lock first and release it when done — release is mandatory cleanup:
- For hardware work outside `hil_test.py` (JLink/GDB, manual flashing, `usbtest.py`, serial poking), hold the lock first and release it when done — release is mandatory cleanup. Proceed only when the lookup and the hold both succeeded; on a failure stop, with no hardware work and no `release` (it would end another session's holder):

```bash
python3 test/hil/helper/hil_lock.py hold BOARD [BOARD...] --reason "why"
LOCK=$(python3 lib/agentrc/skills/hil/scripts/hil_boards.py lock "$CONFIG" BOARD) &&
python3 test/hil/helper/hil_lock.py hold "$LOCK" --reason "why"
# ... hardware work ...
python3 test/hil/helper/hil_lock.py release BOARD [BOARD...]
python3 test/hil/helper/hil_lock.py release "$LOCK"
```

- A hold refused by another holder: `hil_lock.py status` gives the holder and reason to report. A holder reason of `hil_test.py` is a battery run, on `ci` usually a CI job mid-test; waiting and the budget follow `hil` Board locks; a `role: cleanup-holder` holder is reported as a recovery blocker, released only through the owning project's recovery procedure, and `hil_lock.py release` refuses to kill it.
Expand Down Expand Up @@ -341,7 +342,7 @@ busport, unshielded afterwards;
sysrq and the hypervisor rungs need the user and are returned as the blocker in a headless run.
Report the cleanup explicitly: the board's state, plus any shield record or hold still standing.

A `cleanup-pending` board, or a farm check row whose cleanup names an `openocd_esp` receipt, is recovered under a dispatch naming it: where the cell names the protected record as the blocker rather than a token, first `python3 test/hil/helper/hil_lock.py hold --take-over <board> --reason "<recovery dispatch>"`, whose token is the one below (`hil` Board locks); then `python3 lib/agentrc/skills/hil/scripts/hil_openocd_esp.py end --receipt <receipt>`, and only on its exit 0 `python3 test/hil/helper/hil_lock.py release --token <token>`; on exit 1 the board stays held, and one whose init failed or is unproven needs a separately reviewed recovery. An `esptool child, record <record>` receipt has no `end`: its holder's `release --token` refuses until the receipt rechecks `done` (agentrc `hil_process.recheck`), so a child that never ends needs that separately reviewed recovery too. An `openocd_esp session pid <pid> start <start>, receipt file unwritten` receipt is a session whose receipt file could not be written: `end` cannot address it, so it needs that separately reviewed recovery as well.
A `cleanup-pending` board, or a farm check row whose cleanup names an `openocd_esp` receipt, is recovered under a dispatch naming it: where the cell names the protected record as the blocker rather than a token, first `python3 test/hil/helper/hil_lock.py hold --take-over <lock> --reason "<recovery dispatch>"` (`<lock>` the board's lock name, Reservation), whose token is the one below (`hil` Board locks); then `python3 lib/agentrc/skills/hil/scripts/hil_openocd_esp.py end --receipt <receipt>`, and only on its exit 0 `python3 test/hil/helper/hil_lock.py release --token <token>`; on exit 1 the board stays held, and one whose init failed or is unproven needs a separately reviewed recovery. An `esptool child, record <record>` receipt has no `end`: its holder's `release --token` refuses until the receipt rechecks `done` (agentrc `hil_process.recheck`), so a child that never ends needs that separately reviewed recovery too. An `openocd_esp session pid <pid> start <start>, receipt file unwritten` receipt is a session whose receipt file could not be written: `end` cannot address it, so it needs that separately reviewed recovery as well.

## Limits

Expand Down
10 changes: 8 additions & 2 deletions .claude/skills/hil-contract/scripts/hil_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ def helper(name):
'test/hil/pyocd/README.md',
'tools/get_deps.py',
'lib/agentrc/.git/HEAD',
'lib/agentrc/skills/hil/boards.json',
'lib/agentrc/skills/hil/scripts/hil_boards.py',
'lib/agentrc/skills/hil/scripts/hil_flash.py',
'lib/agentrc/skills/hil/scripts/hil_load.py',
'lib/agentrc/skills/hil/scripts/hil_openocd_esp.py',
Expand Down Expand Up @@ -137,8 +139,12 @@ def select_boards(config, args):
fail(f'not in the config: {" ".join(unknown)} (-b takes board names, not variant names)')
selected = boards or list(roster)
if args.flasher or args.exclude_flasher:
selected = [n for n in selected if roster[n]['flasher']['name'] not in args.exclude_flasher
and (not args.flasher or roster[n]['flasher']['name'] in args.flasher)]
try: # each board's flasher is agentrc's (its board registry), as hil_test.py resolves it
kind = {n: helper('hil_util').hil_boards().resolve(roster[n])['flasher']['name'] for n in selected}
except ValueError as e:
fail(f'config: {e}')
selected = [n for n in selected if kind[n] not in args.exclude_flasher
and (not args.flasher or kind[n] in args.flasher)]
if not selected:
fail(f'no board left after the flasher filter (--flasher {args.flasher or "-"}, '
f'--exclude-flasher {args.exclude_flasher or "-"})')
Expand Down
13 changes: 7 additions & 6 deletions .claude/skills/usbtest/scripts/run_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ def kill_children(spare=()):


def resolve(config, board_name, variant):
"""(board, variant name) from the roster, or Refused naming the alternatives."""
"""(board, variant name) from the roster, its flashers from agentrc's board registry, or Refused
naming the alternatives."""
try:
cfg = json.loads(Path(config).read_text())
except (OSError, ValueError) as e:
Expand All @@ -153,10 +154,10 @@ def resolve(config, board_name, variant):
if 'device/usbtest' not in change_impact.board_tests(board):
raise Refused(f'{board_name} does not run device/usbtest in {config} (its "tests" entry); '
f'its device port may not reach this host')
flasher = board.get('flasher')
if not (isinstance(board.get('uid'), str) and board['uid'] and isinstance(flasher, dict)
and isinstance(flasher.get('name'), str) and flasher['name']):
raise Refused(f'{board_name} in {config} needs a "uid" and a "flasher" with a "name"')
try: # its flasher and flasher_recover are agentrc's, by its probe (`hil` skill, Board registry)
board = hil_util.hil_boards().resolve(board)
except ValueError as e:
raise Refused(f'{config}: {e}')
if why := hil_flash.openocd_esp_refused(board, retains_lock=False):
raise Refused(f'{board_name}: {why}')
try:
Expand Down Expand Up @@ -337,7 +338,7 @@ def lock_and_run(args, tests, report, finish):
if not args.allow_concurrent:
check_peers()
try:
lock = hil_lock.acquire_board_lock(board['name'], reason=f'run_case.py usbtest {args.tests}')
lock = hil_lock.acquire_board_lock(hil_util.lock_name(board), reason=f'run_case.py usbtest {args.tests}')
except RuntimeError as e:
raise Refused(f'{board["name"]}: {e}')
if lock is None:
Expand Down
7 changes: 2 additions & 5 deletions .claude/skills/usbtest/scripts/wedge_drill.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,7 @@ def halt_board(board, rec_board, halt_args):
if halt_args is None:
raise Refused(f'{name}: the drill halts through openocd and this board recovers through '
f'{rec_board["flasher"]["name"]}; name the openocd halt with --halt-openocd')
uid = board['flasher'].get('uid')
if not uid:
raise Refused(f'{name}: no probe uid for openocd\'s adapter serial')
halt = {'name': 'openocd', 'uid': uid, 'args': halt_args}
halt = {'name': 'openocd', 'uid': board['flasher']['uid'], 'args': halt_args}
if not hil_flash.convoy_safe(halt):
raise Refused(f'{name}: --halt-openocd {halt_args!r} is not convoy-safe')
return {'name': name, 'flasher': halt}
Expand Down Expand Up @@ -479,7 +476,7 @@ def finish(code):
if not args.allow_concurrent:
run_case.check_peers()
try:
lock = hil_lock.acquire_board_lock(board['name'], reason='wedge_drill.py')
lock = hil_lock.acquire_board_lock(hil_util.lock_name(board), reason='wedge_drill.py')
except RuntimeError as e:
raise Refused(f'{board["name"]}: {e}')
if lock is None:
Expand Down
26 changes: 18 additions & 8 deletions .claude/test/test_gen_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,21 @@ def fixture(root):
(root / 'docs' / 'reference').mkdir(parents=True)
(root / 'test' / 'hil').mkdir(parents=True)
(root / 'test' / 'hil' / 'tinyusb.json').write_text(json.dumps({'boards': [
{'name': 'acme_one', 'tests': {'device': True, 'host': True, 'dual': False},
'flasher': {'name': 'jlink'}, 'variant': [{'name': 'acme_one_hs'}],
'note': 'needs a | jumper \u00f8'},
{'name': 'acme_two', 'tests': {'only': ['host/cdc_msc', 'device/hid']}},
{'name': 'acme_one', 'uid': 'A1', 'probe': 'p1', 'tests': {'device': True, 'host': True, 'dual': False},
'variant': [{'name': 'acme_one_hs'}], 'note': 'needs a | jumper \u00f8'},
{'name': 'acme_two', 'uid': 'A2', 'probe': 'p2', 'tests': {'only': ['host/cdc_msc', 'device/hid']}},
]}, ensure_ascii=False), encoding='utf-8')
(root / 'test' / 'hil' / 'hfp.json').write_text(json.dumps({'boards': []}))


REGISTRY_FLASHERS = {'p1': {'name': 'jlink', 'uid': 'p1'}, 'p2': {'name': 'openocd', 'uid': 'p2'}}


def registry_stub():
"""agentrc's board registry with the fixture boards' flashers, which their roster entries do not carry."""
return mock.Mock(resolve=lambda entry: {**entry, 'flasher': REGISTRY_FLASHERS[entry['probe']]})


class MetadataTest(unittest.TestCase):
def test_block_values_are_stripped_and_missing_file_or_block_is_empty(self):
with tempfile.TemporaryDirectory() as d:
Expand Down Expand Up @@ -103,8 +110,11 @@ def test_writes_the_three_files_and_lists_what_it_left_out(self):
with tempfile.TemporaryDirectory() as d:
root = Path(d)
fixture(root)
r = subprocess.run([sys.executable, str(GEN_DOC), '--root', str(root)], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
with mock.patch.object(sys, 'argv', ['gen_doc.py', '--root', str(root)]), \
mock.patch.object(gen_doc.hil_util, 'hil_boards', registry_stub), \
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()) as err:
rc = gen_doc.main()
self.assertEqual(rc, 0, err.getvalue())
boards = (root / 'docs/reference/boards.rst').read_text(encoding='utf-8')
hil = (root / 'docs/reference/hil_boards.md').read_text(encoding='utf-8')
deps = (root / 'docs/reference/dependencies.rst').read_text()
Expand All @@ -113,15 +123,15 @@ def test_writes_the_three_files_and_lists_what_it_left_out(self):
self.assertNotIn('acme_bare', boards)
self.assertNotIn('nometa', boards)
self.assertTrue(boards.endswith('=\n'))
self.assertEqual(r.stderr.split('\n'), [
self.assertEqual(err.getvalue().split('\n'), [
'no metadata block, left out of boards.rst:',
' hw/bsp/acme/boards/acme_bare',
' hw/bsp/nometa',
'',
])
self.assertTrue(hil.startswith(gen_doc.HEADER + '\n\n### ci rig\n\n2 boards, from `test/hil/tinyusb.json`.'))
self.assertIn('| acme_one | device, host | jlink | acme_one_hs | needs a \\| jumper \u00f8 |', hil)
self.assertIn('| acme_two | device, host | | | |', hil)
self.assertIn('| acme_two | device, host | openocd | | |', hil)
self.assertNotIn('hfp rig', hil)
self.assertIn('lib/fatfs', deps)

Expand Down
Loading
Loading