Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
11 changes: 6 additions & 5 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,7 +186,7 @@ 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.

Expand All @@ -197,9 +197,10 @@ Before the flash, `test/hil/hil_flash.py` takes the build's segments and the chi
- 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:

```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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
```

- 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