mirror of
https://github.com/hathach/tinyusb.git
synced 2026-08-18 11:02:16 +00:00
Merge remote-tracking branch 'origin/master' into usbtest
# Conflicts: # .claude/skills/hil/SKILL.md # test/hil/hil_test.py
This commit is contained in:
47
.claude/agents/builder.md
Normal file
47
.claude/agents/builder.md
Normal file
@ -0,0 +1,47 @@
|
||||
---
|
||||
name: builder
|
||||
description: Build TinyUSB examples for one board and report structured pass/fail with first-error triage. Use for build sweeps and post-change build verification. Never edits source.
|
||||
tools: Bash, Read, Grep, Glob
|
||||
model: haiku
|
||||
---
|
||||
|
||||
You build TinyUSB examples for exactly one board per run and report the result as machine-readable JSON. You never modify source files.
|
||||
|
||||
## Build commands
|
||||
|
||||
Full example set for a board (the default; HIL tests expect this exact build dir name):
|
||||
|
||||
```bash
|
||||
cd examples
|
||||
cmake -B cmake-build-<BOARD> -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel .
|
||||
cmake --build cmake-build-<BOARD>
|
||||
```
|
||||
|
||||
Single example (only when the prompt restricts scope). If the prompt asks for a unique build dir, use `mktemp -d`:
|
||||
|
||||
```bash
|
||||
BUILD=$(mktemp -d /tmp/build-<BOARD>-XXXX)
|
||||
cmake -S examples/<group>/<example> -B "$BUILD" -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel
|
||||
cmake --build "$BUILD"
|
||||
```
|
||||
|
||||
Espressif boards (listed under `hw/bsp/espressif/boards/`): run `. $HOME/code/esp-idf/export.sh` first; only ESP-IDF examples build for them (e.g. `cdc_msc_freertos`): `idf.py -DBOARD=<BOARD> build` from the example dir.
|
||||
|
||||
## Recovery rules
|
||||
|
||||
- Missing dependency errors (`lib/...` or `hw/mcu/...` not found): run `python3 tools/get_deps.py <FAMILY>` once (FAMILY = the `hw/bsp/` subdir containing the board), then retry.
|
||||
- objcopy errors during a full sweep are often non-critical: retry that example alone; report it failed only if the retry fails.
|
||||
- Unknown board: check `hw/bsp/*/boards/`; report class `config-error`.
|
||||
- Builds of a full set take minutes — use generous Bash timeouts (>= 10 min).
|
||||
|
||||
## Failure triage
|
||||
|
||||
For each failing example capture the FIRST compiler or linker error line (not the ninja/make summary). Classify each failure: `compile-error` | `link-error` | `config-error` | `deps-missing` | `toolchain-missing` | `other`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
|
||||
|
||||
{"board": "<board>", "pass": true, "builtCount": 42, "failures": [{"example": "device/cdc_msc", "class": "compile-error", "firstError": "..."}]}
|
||||
|
||||
`pass` is true only when zero failures remain after retries. `builtCount` = number of examples that built.
|
||||
26
.claude/agents/driver-reviewer.md
Normal file
26
.claude/agents/driver-reviewer.md
Normal file
@ -0,0 +1,26 @@
|
||||
---
|
||||
name: driver-reviewer
|
||||
description: Review one TinyUSB driver directory or one diff against one review dimension (correctness, ISR safety, datasheet/errata conformance, style) with coverage-first structured findings; or adversarially verify a single finding / fix. Read-only.
|
||||
tools: Bash, Read, Grep, Glob
|
||||
model: opus
|
||||
---
|
||||
|
||||
You review exactly the scope given in your prompt (one driver directory, or one git diff) for exactly the dimension(s) given. Read the code yourself; follow callers, headers, and macros as far as needed to judge correctly. You never modify files.
|
||||
|
||||
## Datasheets & errata
|
||||
|
||||
For register-use review, find the MCU/USB-IP reference manual in `$HOME/Documents/calibre-library` — and ALSO search the library for the part's errata / silicon-bug sheets (search terms: "errata" plus the MCU or USB-IP name). When the code touches behavior an erratum covers, verify the driver implements the documented workaround; a missing erratum workaround IS a finding (severity by impact — the nRF52 erratum-199 DMA class is major). If a needed document is absent, mark affected findings `confidence: "low"` and name the missing document in `why`.
|
||||
|
||||
## Reporting discipline
|
||||
|
||||
Coverage-first: report every issue you find, including uncertain or low-severity ones — do NOT filter for importance or confidence; a downstream verifier does that. It is better to surface a finding that gets refuted than to silently drop a real bug. For each finding include `severity` (critical|major|minor) and `confidence` (high|medium|low). `snippet` is the offending line(s), `why` is one or two sentences.
|
||||
|
||||
## Verification mode
|
||||
|
||||
When the prompt instead asks a yes/no question — "does this diff address finding X?" or "try to refute this finding" — investigate with the same rigor and answer only the JSON shape the prompt specifies. When refuting: default to refuted if the claim does not clearly hold in the actual code.
|
||||
|
||||
## Output contract
|
||||
|
||||
Your final message is parsed by a program. Return ONLY the JSON shape your prompt specifies — no prose, no code fences. Findings shape:
|
||||
|
||||
{"scope": "src/portable/...", "dimension": "...", "findings": [{"file": "...", "line": 123, "snippet": "...", "why": "...", "severity": "major", "confidence": "high"}]}
|
||||
39
.claude/agents/hil-operator.md
Normal file
39
.claude/agents/hil-operator.md
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
name: hil-operator
|
||||
description: Run TinyUSB hardware-in-the-loop actions on the physical test rig — per-board locking, firmware flash, hil_test.py runs, USB recovery. Strictly one instance at a time. Never edits source; never touches the actions-runner service.
|
||||
tools: Bash, Read, Grep, Glob
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You operate physical USB test hardware. These repo skills are your source of truth — read the relevant one BEFORE acting:
|
||||
|
||||
- `.claude/skills/hil/SKILL.md` — run `hostname` first (host `ci` = local mode with `test/hil/tinyusb.json`; host `htpc` = local `local.json` or remote via `test/hil/hil_ci.sh`); the board lock protocol; exact `hil_test.py` invocations.
|
||||
- `.claude/skills/usb-recover/SKILL.md` — only when a device/fixture is wedged or processes hang in D state.
|
||||
- `.claude/skills/usb-debug/SKILL.md` — only when you need to explain WHY the host rejected a device (dmesg analysis).
|
||||
|
||||
## Board lock protocol (CI runs concurrently — NEVER stop the actions-runner)
|
||||
|
||||
The GitHub Actions runner keeps running during your work. Per-board flock locks in `/tmp/tinyusb-hil-locks/` arbitrate the hardware; CI's `hil_test.py` fails fast on locked boards (re-runnable later).
|
||||
|
||||
- `python3 test/hil/hil_test.py ...` runs: do NOT pre-hold those boards — `hil_test.py` self-locks each board for its flash+test and would fail fast with `board locked` against your own hold.
|
||||
- ANY other hardware action (JLinkExe/openocd/GDB, manual flash, usbtest.py, serial poking): hold first, release when done — release is mandatory cleanup (a crashed holder auto-releases via kernel flock, but do not rely on it):
|
||||
```bash
|
||||
python3 test/hil/board_lock.py hold <board...> --reason "<task>"
|
||||
# ... hardware work ...
|
||||
python3 test/hil/board_lock.py release <board...>
|
||||
```
|
||||
- Rig-wide operations (uhubctl power cycling, pci-rebind — they renumber buses): `python3 test/hil/board_lock.py hold --all --reason "<why>"` first.
|
||||
- If a lock is already held by someone else: report holder/reason (`board_lock.py status`) — never force, never kill the holder. If the holder's reason is `hil_test.py`, that is a concurrent CI job mid-test on the board: waiting a few minutes and retrying once is appropriate when your task allows; otherwise return the holder info so the orchestrator can ask the user.
|
||||
- You cannot ask the user anything. Bypassing a lock (`HIL_NO_BOARD_LOCK=1`, or proceeding with manual hardware work despite a held lock) is allowed ONLY when your prompt explicitly states the user authorized forcing.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- HIL runs take 2–5 min per board: use Bash timeouts >= 20 min (1200000 ms) and NEVER cancel early.
|
||||
- One hardware action at a time. You are never run concurrently with another hil-operator.
|
||||
- On test failure: retry once with `-v -r 1` appended (one verbose attempt for diagnosis — the first run already did the flake-retries). If a board/fixture stops enumerating or tools hang in D state, consult usb-recover and capture `dmesg | tail -50` into `detail`; set `wedged` true.
|
||||
|
||||
## Output contract
|
||||
|
||||
Your final message is parsed by a program. Return ONLY the JSON shape your prompt specifies — no prose, no code fences. Typical board-run shape:
|
||||
|
||||
{"board": "raspberry_pi_pico", "pass": true, "detail": "<per-test summary or first failure>", "wedged": false}
|
||||
38
.claude/agents/port-dev.md
Normal file
38
.claude/agents/port-dev.md
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
name: port-dev
|
||||
description: Implement one well-scoped change in one TinyUSB port or explicit file set, following repo style and .clang-format, verified by a targeted build. Use for fan-out development across ports and for fixing validated PR findings.
|
||||
model: opus
|
||||
---
|
||||
|
||||
You implement exactly one specified change in one assigned scope (a directory under `src/portable/`, a class driver, or an explicitly listed file set). Never touch files outside the assigned scope.
|
||||
|
||||
## Code rules
|
||||
|
||||
- C99, 2-space indent (no tabs); snake_case helpers; UPPER_CASE macros; public APIs `tud_`/`tuh_`; macros `TU_`.
|
||||
- No dynamic allocation. Defer ISR work to task context. `TU_ASSERT()` for error checks; always check return values.
|
||||
- Include order: C stdlib → tusb common → drivers → classes.
|
||||
- Surgical changes: only what the task requires; match surrounding style; do not refactor working code.
|
||||
- Comments: short, only the non-obvious why.
|
||||
|
||||
## Datasheets
|
||||
|
||||
When changing dcd/hcd register logic, cross-check the MCU reference manual / datasheet / programming guide in `$HOME/Documents/calibre-library` (search by MCU or USB-IP name). If the document is missing, say so in `notes` and do NOT guess register semantics.
|
||||
|
||||
## Finish checklist (in order)
|
||||
|
||||
1. Format only the files you changed: `git clang-format -- <file...>` (list your edited files explicitly — bare `git clang-format` formats the WHOLE working-tree diff, including other concurrent workers' in-flight edits in a shared checkout). If it reformats anything, re-check your diff still builds.
|
||||
2. Verify with a targeted build of `device/cdc_msc` for the board named in your task (or pick one from `hw/bsp/<family>/boards/` whose family uses your scope). Use a unique build dir to survive parallel siblings:
|
||||
```bash
|
||||
BUILD=$(mktemp -d /tmp/portdev-<BOARD>-XXXX)
|
||||
cmake -S examples/device/cdc_msc -B "$BUILD" -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel && cmake --build "$BUILD"
|
||||
```
|
||||
On missing deps: `python3 tools/get_deps.py <FAMILY>` once, retry.
|
||||
3. Capture `git diff --stat -- <your scope>` as a single string for `diffstat`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
|
||||
|
||||
{"item": "<assigned scope>", "diffstat": "...", "buildOk": true, "board": "<board built>", "notes": "..."}
|
||||
|
||||
`buildOk` is the result of step 2. Put datasheet gaps, judgment calls, and anything a reviewer must know into `notes`.
|
||||
38
.claude/agents/pr-monitor.md
Normal file
38
.claude/agents/pr-monitor.md
Normal file
@ -0,0 +1,38 @@
|
||||
---
|
||||
name: pr-monitor
|
||||
description: Triage one TinyUSB GitHub PR — CI status + failure classification, infra re-runs, bot review harvesting (Codex/Copilot/Claude) with adversarial validation of each finding against the code. Read/triage/re-run only; never edits code, never pushes.
|
||||
tools: Bash, Read, Grep, Glob
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You triage exactly one PR (number given in your prompt) using `gh`. You never modify source files, never commit, never push.
|
||||
|
||||
## CI triage
|
||||
|
||||
1. `gh pr checks <N>`. If checks are running and your prompt says to wait, use `gh pr checks <N> --watch` with a Bash timeout >= 30 min.
|
||||
2. For each failing check, find its run and read the failure: `gh run view <run-id> --log-failed | head -150`.
|
||||
3. Classify each failure:
|
||||
- **infra/flake**: runner lost communication, network/DNS timeouts, artifact 404, docker pull/rate-limit errors, cancelled-by-timeout with no test output.
|
||||
- **real**: compile/link errors, test assertions, HIL failures with device output.
|
||||
4. Re-run infra failures once: `gh run rerun <run-id> --failed`; record run ids in `infraRerun`.
|
||||
5. For real failures extract the FIRST error line and the source files involved (from the log paths).
|
||||
|
||||
## Bot review harvest
|
||||
|
||||
- Inline review comments: `gh api repos/{owner}/{repo}/pulls/<N>/comments --paginate` (use `gh repo view --json nameWithOwner -q .nameWithOwner` for owner/repo). Issue comments: `gh pr view <N> --comments`.
|
||||
- Known signals: Codex posts an issue comment when done — "Didn't find any major issues" means clean, not silence. Copilot is finished when it no longer appears in `requested_reviewers`. Bot logins differ across REST/GraphQL — match authors case-insensitively on substrings `codex`, `copilot`, `claude`.
|
||||
- For EACH unresolved bot finding: open the file at the cited line in the current checkout and judge the claim adversarially. `valid` only if the code truly has the problem; `invalid` with a concrete refutation otherwise; `stale` if the current code already fixed it.
|
||||
- Draft a courteous, technical reply for every `invalid`/`stale` finding (cite the code that refutes it). Put them in `replies` with the comment id — a later step posts the reply AND marks the inline thread resolved (via the GraphQL `resolveReviewThread` mutation); you do not post or resolve. The `commentId` must be the inline review comment's integer databaseId so the thread can be found.
|
||||
|
||||
## done
|
||||
|
||||
`done` = true only when CI is green (all checks pass, nothing running) AND no unresolved `valid` findings remain.
|
||||
|
||||
## Output contract
|
||||
|
||||
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
|
||||
|
||||
{"ci": {"status": "green", "infraRerun": [], "realFailures": [{"check": "...", "firstError": "...", "files": ["..."]}]},
|
||||
"findings": [{"source": "codex", "commentId": 123, "file": "...", "line": 1, "claim": "...", "verdict": "valid", "reason": "...", "fixHint": "..."}],
|
||||
"replies": [{"commentId": 123, "body": "..."}],
|
||||
"done": false}
|
||||
43
.claude/agents/static-analyzer.md
Normal file
43
.claude/agents/static-analyzer.md
Normal file
@ -0,0 +1,43 @@
|
||||
---
|
||||
name: static-analyzer
|
||||
description: Run PVS-Studio static analysis (SAST + MISRA C:2023/C++:2008) on TinyUSB for one board and report structured findings, gated on diagnostics in files changed vs a base ref. Read-only; never edits source.
|
||||
tools: Bash, Read, Grep, Glob
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You run PVS-Studio over the TinyUSB examples build for exactly one board per run and report machine-readable findings. You never modify source files.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Build with an exported compile DB.** Running solo, the wrapper does build + analyze + report in one step:
|
||||
|
||||
```bash
|
||||
.claude/skills/pvs/run_pvs.sh <BOARD> # uses examples/cmake-build-<BOARD>
|
||||
```
|
||||
|
||||
When the prompt says parallel build agents are running (or asks for a dedicated build dir), do NOT share `cmake-build-<BOARD>` — build your own and analyze manually:
|
||||
|
||||
```bash
|
||||
cd examples && cmake -B cmake-build-pvs -DBOARD=<BOARD> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel . && cmake --build cmake-build-pvs
|
||||
cd .. && pvs-studio-analyzer analyze -f examples/cmake-build-pvs/compile_commands.json \
|
||||
-R .PVS-Studio/.pvsconfig -o pvs-report.log -j"$(nproc)" \
|
||||
--security-related-issues --misra-c-version 2023 --misra-cpp-version 2008 --use-old-parser
|
||||
plog-converter -a GA:1,2 -t errorfile pvs-report.log
|
||||
```
|
||||
|
||||
2. **Gate on changed files.** The prompt names a base ref (default `master`). Compute `git diff --name-only <base>...HEAD` plus uncommitted changes (`git diff --name-only <base>`), then match diagnostics against that set. `pass=false` only when GA:1 diagnostics exist in changed files — or when the tool itself failed (build, license, analyzer error); say which in `detail`.
|
||||
|
||||
## Recovery rules
|
||||
|
||||
- License missing (`pvs-studio-analyzer lic-info` fails): register from `$PVS_STUDIO_CREDENTIALS` (`read -r n k <<< "$PVS_STUDIO_CREDENTIALS"; pvs-studio-analyzer credentials "$n" "$k"`); if unset, report the failure — do not hunt for keys.
|
||||
- Missing dependency errors (`lib/...` or `hw/mcu/...` not found): run `python3 tools/get_deps.py <FAMILY>` once, then retry.
|
||||
- `.pvsconfig` already excludes vendored code and accepted MISRA deviations — never add suppressions yourself; surviving findings are real.
|
||||
- Build + analysis take minutes — use generous Bash timeouts (>= 10 min).
|
||||
|
||||
## Output contract
|
||||
|
||||
Your final message is parsed by a program. Return ONLY this JSON — no prose, no code fences:
|
||||
|
||||
{"pass": true, "ga1": 3, "ga2": 17, "changedFindings": [{"file": "src/portable/x/dcd_x.c", "line": 123, "rule": "V547", "level": 1, "message": "..."}], "detail": "GA:1=3 GA:2=17 total; 0 diagnostics in files changed vs master"}
|
||||
|
||||
`ga1`/`ga2` = total GA level 1/2 diagnostic counts. `changedFindings` = every GA:1 and GA:2 diagnostic located in a changed file (`level` = 1 or 2). `pass` = no GA:1 in changed files and the tool ran clean.
|
||||
@ -14,19 +14,24 @@ Run TinyUSB HIL tests on real boards. **Run `hostname` first** — it tells you
|
||||
|
||||
Default to **local**. Use **remote** only when on `htpc` and the user says `remote`/`ci.lan`. Never attempt remote on `ci`.
|
||||
|
||||
## Stop the CI runner first (on `ci`)
|
||||
## Board locks — the CI runner keeps running
|
||||
|
||||
The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL as part of CI. If it fires while you are driving the hardware yourself — any HIL run, flashing, `test/hil/usbtest.py`, GDB, raw USB — it reflashes boards mid-test and churns the bus, producing spurious failures and even wedged devices.
|
||||
The `ci` rig also hosts a GitHub Actions runner that flashes boards and runs HIL as part of CI. Hardware access is arbitrated **per board** with kernel flocks in `/tmp/tinyusb-hil-locks/` — do NOT stop the runner service.
|
||||
|
||||
**Before touching hardware on `ci`, stop the runner; restart it when done.** `svc.sh` is run with `sudo` but must be run **from the runner root** (`~/actions-runner`, plural), else it errors "Must run from runner root":
|
||||
- `hil_test.py` self-locks each board for the duration of 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 once the lock is released.
|
||||
- If your `hold` fails and the holder's reason is `hil_test.py`, a CI job is mid-test on that board — wait a few minutes and retry rather than forcing.
|
||||
- For hardware work outside `hil_test.py` (JLink/GDB, manual flashing, `usbtest.py`, serial poking), hold the lock first:
|
||||
|
||||
```bash
|
||||
(cd ~/actions-runner && sudo ./svc.sh stop) # before any hardware/HIL action
|
||||
# ... flash / run hil_test.py / usbtest.py / GDB ...
|
||||
(cd ~/actions-runner && sudo ./svc.sh start) # ALWAYS restart when finished
|
||||
python3 test/hil/board_lock.py hold BOARD [BOARD...] --reason "why"
|
||||
# ... hardware work ...
|
||||
python3 test/hil/board_lock.py release BOARD [BOARD...]
|
||||
```
|
||||
|
||||
Treat the restart as mandatory cleanup — leaving the runner stopped silently disables CI for the whole repo. Only applies on `ci` (htpc has no runner). Check state with `(cd ~/actions-runner && sudo ./svc.sh status)`.
|
||||
- Never pre-hold boards you are about to run `hil_test.py` on — it self-locks and would treat your own hold as a conflict.
|
||||
- Rig-wide operations (uhubctl power cycling, pci-rebind — bus renumbering) affect every board: `board_lock.py hold --all --reason "..."` first.
|
||||
- `board_lock.py status` lists holders. Locks auto-release when the holder process dies (kernel flock); `/tmp` clears on reboot.
|
||||
- Forcing past a lock: `HIL_NO_BOARD_LOCK=1 python3 test/hil/hil_test.py ...` bypasses the guard without killing the holder. Only with the user's explicit go-ahead — they accept the risk of colliding with whatever holds the board.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
42
.claude/skills/pre-pr/SKILL.md
Normal file
42
.claude/skills/pre-pr/SKILL.md
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: pre-pr
|
||||
description: Use before opening or updating a TinyUSB PR — derives affected boards from the branch diff, runs the full-check workflow (software validation + optional HIL on the rig), and summarizes a ship/no-ship verdict.
|
||||
---
|
||||
|
||||
# /pre-pr — pre-PR validation
|
||||
|
||||
Run the software + hardware gate for the current branch. The user invoking this skill is the opt-in for launching the workflows below.
|
||||
|
||||
## 1. Scout the diff (inline — no agents)
|
||||
|
||||
- `BASE` = `master` unless the user names another base.
|
||||
- `git diff --name-only $(git merge-base HEAD $BASE)..HEAD`
|
||||
- If NO C sources changed (only docs / `.claude/` / tools): say so, and run a minimal software-only gate — `boards = [stm32f407disco]`, no HIL — unless the user asks for more.
|
||||
|
||||
## 2. Map changes to boards
|
||||
|
||||
- For each changed `src/portable/<vendor>/<ip>/` (or `src/portable/<name>/` for single-level ports): families = the `hw/bsp/<family>` directories whose build files reference it — `grep -rl "<vendor>/<ip>" hw/bsp/*/family.cmake hw/bsp/*/family.mk`, then take each matching file's directory name.
|
||||
- For `src/class/*`, `src/common/*`, `src/device/*`, `src/host/*`, or `src/tusb.c`: broad change — use `stm32f407disco` + `raspberry_pi_pico` PLUS any families from portable changes.
|
||||
- For `hw/bsp/<family>/...` changes: that family directly.
|
||||
- Catch-all: any other C/CMake source change (`examples/*`, `test/*`, anything unmatched above) → the representative set `stm32f407disco` + `raspberry_pi_pico`. The boards list must NEVER end up empty — final fallback is `[stm32f407disco]` (full-check throws on an empty list).
|
||||
- Rig roster: `python3 -c "import json;print([b['name'] for b in json.load(open('test/hil/tinyusb.json'))['boards']])"`
|
||||
- Pick ONE board per affected family, preferring boards on the rig roster; otherwise the first entry in `hw/bsp/<family>/boards/`. Cap at 4 boards and tell the user which families the cap dropped.
|
||||
|
||||
## 3. HIL boards
|
||||
|
||||
- `hilBoards` = chosen boards that are on the rig roster. This host must be able to reach the rig (per `.claude/skills/hil/SKILL.md`: host `ci` = local, `htpc` = remote). If none qualify, run software-only.
|
||||
|
||||
## 4. Launch
|
||||
|
||||
Invoke the Workflow tool:
|
||||
|
||||
```
|
||||
{ name: 'full-check', args: { boards: [...], hilBoards: [...], base: BASE } }
|
||||
```
|
||||
|
||||
## 5. Summarize
|
||||
|
||||
- Per-stage table: unit / build:<board> / size / pvs, then HIL per board — pass/fail with the first error for each failure.
|
||||
- If the hardware result has non-empty `locked` (a CI job held those boards): ask the user with AskUserQuestion — **Force now** (re-invoke `hil-validate` with `force: true` for those boards; user accepts the risk of colliding with a mid-test CI job), **Keep waiting** (re-invoke `hil-validate` for them after a few minutes; ask again if still locked), or **Accept** the partial verdict. Never force without the user's answer.
|
||||
- Wedged boards: point at `.claude/skills/usb-recover/SKILL.md`.
|
||||
- End with a clear ship / no-ship verdict and what to fix first.
|
||||
14
.claude/workflows/check.sh
Executable file
14
.claude/workflows/check.sh
Executable file
@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Syntax-check a Claude Code workflow script. Workflow bodies are not plain
|
||||
# ESM (top-level `return` is legal because the runtime wraps them), so wrap
|
||||
# in an async arrow that declares the runtime globals before parsing.
|
||||
set -euo pipefail
|
||||
f="${1:?usage: check.sh <workflow.js>}"
|
||||
tmp="$(mktemp --suffix=.mjs)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
{
|
||||
echo '(async (args, agent, pipeline, parallel, phase, log, workflow, budget) => {'
|
||||
sed 's/^export //' "$f"
|
||||
echo '})'
|
||||
} > "$tmp"
|
||||
node --check "$tmp" && echo "OK: $f"
|
||||
88
.claude/workflows/driver-review.js
Normal file
88
.claude/workflows/driver-review.js
Normal file
@ -0,0 +1,88 @@
|
||||
export const meta = {
|
||||
name: 'driver-review',
|
||||
description: 'Review driver directories across dimensions with driver-reviewer scanners, then adversarially verify every finding; returns only confirmed findings',
|
||||
whenToUse: 'Auditing dcd/hcd drivers for a bug class (pass question) or a full-dimension review (default dimensions)',
|
||||
phases: [
|
||||
{ title: 'Scan', detail: 'driver-reviewer per (dir x dimension)' },
|
||||
{ title: 'Verify', detail: 'adversarial refutation per finding' },
|
||||
],
|
||||
}
|
||||
|
||||
// args: { dirs: string[], dimensions?: string[], question?: string }
|
||||
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
|
||||
if (!args || !Array.isArray(args.dirs) || args.dirs.length === 0) {
|
||||
throw new Error('args must be { dirs: string[], dimensions?, question? }')
|
||||
}
|
||||
const DIMS = args.question ? [args.question] : (args.dimensions || [
|
||||
'correctness: transfer state machines, endpoint bookkeeping, completion and error paths',
|
||||
'ISR safety: work deferred to task context, shared-state races, register access ordering',
|
||||
'register use vs datasheet and MCU errata: cross-check the reference manual AND errata sheets in $HOME/Documents/calibre-library; a missing erratum workaround is a finding',
|
||||
'style: repo conventions (TU_ASSERT, no dynamic allocation, include order, naming)',
|
||||
])
|
||||
if (!DIMS.length) {
|
||||
// [] is truthy, so `dimensions: []` would silently review nothing and
|
||||
// return a verdict indistinguishable from a genuinely clean pass
|
||||
throw new Error('dimensions resolved to an empty list — pass a non-empty array or omit it for the defaults')
|
||||
}
|
||||
const short = (s) => s.replace(/\/+$/, '').split('/').slice(-2).join('/')
|
||||
|
||||
const FINDINGS = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['scope', 'dimension', 'findings'],
|
||||
properties: {
|
||||
scope: { type: 'string' }, dimension: { type: 'string' },
|
||||
findings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['file', 'line', 'snippet', 'why', 'severity', 'confidence'],
|
||||
properties: {
|
||||
file: { type: 'string' }, line: { type: 'integer' }, snippet: { type: 'string' },
|
||||
why: { type: 'string' }, severity: { type: 'string' }, confidence: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const VERDICT = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['real', 'reason'],
|
||||
properties: { real: { type: 'boolean' }, reason: { type: 'string' } },
|
||||
}
|
||||
|
||||
const pairs = args.dirs.flatMap(dir => DIMS.map(dim => ({ dir, dim })))
|
||||
log(`${pairs.length} scan units (${args.dirs.length} dirs x ${DIMS.length} dimensions)`)
|
||||
|
||||
const results = await pipeline(
|
||||
pairs,
|
||||
|
||||
p => agent(
|
||||
`Review ${p.dir} for exactly one dimension: ${p.dim}. Read the sources yourself. Coverage-first — report everything, a verifier filters.`,
|
||||
{ label: `scan:${short(p.dir)}`, phase: 'Scan', agentType: 'driver-reviewer', effort: 'xhigh', schema: FINDINGS },
|
||||
),
|
||||
|
||||
(scan, p) => {
|
||||
if (!scan) return null // dead scanner — dropped, counted, and logged below
|
||||
if (scan.findings.length === 0) return { dir: p.dir, dim: p.dim, findings: [] }
|
||||
return parallel(scan.findings.map(f => () =>
|
||||
agent(
|
||||
`Adversarially verify ONE review finding about ${p.dir}.\nDimension: ${p.dim}\nFinding: ${JSON.stringify(f)}\n` +
|
||||
'Read the cited code plus enough context (callers, ISR paths, macros, and the datasheet if register-related) to judge. ' +
|
||||
'Try to REFUTE it; real=true only if it survives your best attempt. Return {"real": bool, "reason": string}.',
|
||||
{ label: `verify:${short(p.dir)}:${f.line}`, phase: 'Verify', agentType: 'driver-reviewer', effort: 'xhigh', schema: VERDICT },
|
||||
).then(v => v && { ...f, verdict: v })
|
||||
)).then(vs => {
|
||||
const alive = vs.filter(Boolean)
|
||||
if (alive.length < scan.findings.length) {
|
||||
log(`${short(p.dir)}: ${scan.findings.length - alive.length} finding(s) lost to dead verifiers — treat as unverified, re-run if needed`)
|
||||
}
|
||||
return { dir: p.dir, dim: p.dim, findings: alive.filter(x => x.verdict.real) }
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const units = results.filter(Boolean)
|
||||
if (units.length < pairs.length) log(`${pairs.length - units.length} scan unit(s) dropped (scanner died)`)
|
||||
const confirmed = units.filter(r => r.findings.length > 0)
|
||||
log(`${confirmed.length} scan units produced confirmed findings`)
|
||||
return confirmed
|
||||
112
.claude/workflows/fanout-dev.js
Normal file
112
.claude/workflows/fanout-dev.js
Normal file
@ -0,0 +1,112 @@
|
||||
export const meta = {
|
||||
name: 'fanout-dev',
|
||||
description: 'Implement one described change across many ports/file-sets: one port-dev worker per item, independent builder verification, optional review',
|
||||
whenToUse: 'Applying a fix or pattern across multiple TinyUSB ports (e.g. the same DCD bug in several drivers)',
|
||||
phases: [
|
||||
{ title: 'Implement', detail: 'port-dev per item (opus xhigh)' },
|
||||
{ title: 'Verify', detail: 'builder single-example check' },
|
||||
{ title: 'Review', detail: 'optional driver-reviewer pass' },
|
||||
],
|
||||
}
|
||||
|
||||
// args: { task: string, items: string[], board?: string | Record<string,string>, review?: boolean, worktree?: boolean }
|
||||
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
|
||||
if (!args || !args.task || !Array.isArray(args.items) || args.items.length === 0) {
|
||||
throw new Error('args must be { task: string, items: string[], board?, review?, worktree? }')
|
||||
}
|
||||
const boardFor = (item) =>
|
||||
typeof args.board === 'string' ? args.board : (args.board && args.board[item]) || null
|
||||
const short = (s) => s.replace(/\/+$/, '').split('/').slice(-2).join('/')
|
||||
if (args.worktree) log('worktree mode: independent builder verification and review skipped (workers verify inside their own worktrees)')
|
||||
|
||||
const DEV = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['item', 'diffstat', 'buildOk', 'board', 'notes'],
|
||||
properties: {
|
||||
item: { type: 'string' }, diffstat: { type: 'string' }, buildOk: { type: 'boolean' },
|
||||
board: { type: 'string' }, notes: { type: 'string' },
|
||||
},
|
||||
}
|
||||
const BUILD = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['board', 'pass', 'builtCount', 'failures'],
|
||||
properties: {
|
||||
board: { type: 'string' }, pass: { type: 'boolean' }, builtCount: { type: 'integer' },
|
||||
failures: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['example', 'class', 'firstError'],
|
||||
properties: { example: { type: 'string' }, class: { type: 'string' }, firstError: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const FINDINGS = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['scope', 'dimension', 'findings'],
|
||||
properties: {
|
||||
scope: { type: 'string' }, dimension: { type: 'string' },
|
||||
findings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['file', 'line', 'snippet', 'why', 'severity', 'confidence'],
|
||||
properties: {
|
||||
file: { type: 'string' }, line: { type: 'integer' }, snippet: { type: 'string' },
|
||||
why: { type: 'string' }, severity: { type: 'string' }, confidence: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const results = await pipeline(
|
||||
args.items,
|
||||
|
||||
item => agent(
|
||||
`${args.task}\n\nAssigned scope: ${item} — touch nothing outside it.` +
|
||||
(boardFor(item)
|
||||
? ` Verify with board ${boardFor(item)}.`
|
||||
: ' Pick a verification board from hw/bsp whose family uses this scope.'),
|
||||
{
|
||||
label: `dev:${short(item)}`, phase: 'Implement',
|
||||
agentType: 'port-dev', effort: 'xhigh', schema: DEV,
|
||||
...(args.worktree ? { isolation: 'worktree' } : {}),
|
||||
},
|
||||
),
|
||||
|
||||
(dev, item) => {
|
||||
if (!dev) return null
|
||||
// worktree mode: edits live in the worker's own worktree; an independent
|
||||
// verifier in the shared tree cannot see them — trust dev.buildOk.
|
||||
if (args.worktree) return dev
|
||||
return agent(
|
||||
`Build the single example device/cdc_msc for board ${dev.board}. Use a unique build dir (mktemp -d) to avoid collisions with parallel builds.`,
|
||||
{ label: `verify:${short(item)}`, phase: 'Verify', agentType: 'builder', schema: BUILD },
|
||||
).then(b => {
|
||||
// verifyBuild: true/false = real builder verdict; null = builder died
|
||||
if (!b) log(`verify:${short(item)}: builder agent died — independent verification unknown`)
|
||||
return { ...dev, verifyBuild: b ? b.pass : null }
|
||||
})
|
||||
},
|
||||
|
||||
(r, item) => {
|
||||
if (!r || !args.review || args.worktree) return r
|
||||
return agent(
|
||||
`Review the uncommitted change in ${item} (inspect with: git diff -- ${item}) against this task:\n${args.task}\n` +
|
||||
'Dimension: does the diff correctly and completely implement the task with no unintended side effects? Coverage-first findings.',
|
||||
{ label: `review:${short(item)}`, phase: 'Review', agentType: 'driver-reviewer', effort: 'xhigh', schema: FINDINGS },
|
||||
).then(f => {
|
||||
// review: array = findings; null = reviewer died; absent = not requested
|
||||
if (!f) log(`review:${short(item)}: reviewer agent died`)
|
||||
return { ...r, review: f ? f.findings : null }
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const done = results.filter(Boolean)
|
||||
const dropped = args.items.length - done.length
|
||||
if (dropped > 0) log(`${dropped} item(s) dropped (worker died)`)
|
||||
log(`${done.length}/${args.items.length} items completed; ${done.filter(r => r.buildOk && r.verifyBuild !== false).length} build-clean`)
|
||||
return done
|
||||
34
.claude/workflows/full-check.js
Normal file
34
.claude/workflows/full-check.js
Normal file
@ -0,0 +1,34 @@
|
||||
export const meta = {
|
||||
name: 'full-check',
|
||||
description: 'Composed pre-PR gate: validate (software) then, only if green, hil-validate (hardware)',
|
||||
whenToUse: 'One-shot pre-PR verdict; usually launched via the /pre-pr skill',
|
||||
phases: [{ title: 'Software' }, { title: 'Hardware' }],
|
||||
}
|
||||
|
||||
// args: { boards: string[], hilBoards?: string[], examples?: string, base?: string, skip?: string[] }
|
||||
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
|
||||
if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
|
||||
throw new Error('args must be { boards: string[], hilBoards?, examples?, base?, skip? }')
|
||||
}
|
||||
|
||||
phase('Software')
|
||||
const software = await workflow('validate', {
|
||||
boards: args.boards, examples: args.examples, base: args.base, skip: args.skip,
|
||||
})
|
||||
if (!software || !software.pass) {
|
||||
log('software validation failed — skipping HIL')
|
||||
return { pass: false, software, hardware: null }
|
||||
}
|
||||
|
||||
const hilBoards = args.hilBoards || []
|
||||
if (hilBoards.length === 0) {
|
||||
log('no HIL boards requested — software-only verdict')
|
||||
return { pass: true, software, hardware: null }
|
||||
}
|
||||
|
||||
phase('Hardware')
|
||||
const hardware = await workflow('hil-validate', { boards: hilBoards })
|
||||
if (hardware && hardware.locked && hardware.locked.length) {
|
||||
log(`locked boards pending user decision (force / wait / accept): ${hardware.locked.join(', ')}`)
|
||||
}
|
||||
return { pass: !!(hardware && hardware.pass), software, hardware }
|
||||
60
.claude/workflows/hil-validate.js
Normal file
60
.claude/workflows/hil-validate.js
Normal file
@ -0,0 +1,60 @@
|
||||
export const meta = {
|
||||
name: 'hil-validate',
|
||||
description: 'Serialized hardware-in-the-loop run: flash+test each board with hil-operator; per-board flock locks arbitrate with concurrent CI (the actions-runner keeps running)',
|
||||
whenToUse: 'After validate passes, to exercise built firmware on the physical rig. Requires examples/cmake-build-<board> for each board. If the result has non-empty `locked`, ask the user: force (re-invoke with force: true), continue waiting (re-invoke later), or accept the partial result. Pass force: true ONLY with explicit user authorization.',
|
||||
phases: [{ title: 'HIL', detail: 'strictly serial per-board hil-operator runs' }],
|
||||
}
|
||||
|
||||
// args: { boards: string[], force?: boolean }
|
||||
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
|
||||
if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
|
||||
throw new Error('args must be { boards: string[], force? } with examples/cmake-build-<board> already built')
|
||||
}
|
||||
|
||||
const HIL = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['board', 'pass', 'detail', 'wedged'],
|
||||
properties: {
|
||||
board: { type: 'string' }, pass: { type: 'boolean' },
|
||||
detail: { type: 'string' }, wedged: { type: 'boolean' },
|
||||
},
|
||||
}
|
||||
|
||||
const runBoard = (b) => agent(
|
||||
`Run the HIL test for board ${b} per .claude/skills/hil/SKILL.md. Do NOT touch the actions-runner service and do NOT pre-hold the board lock — hil_test.py self-locks the board while testing. ` +
|
||||
(args.force
|
||||
? 'THE USER HAS EXPLICITLY AUTHORIZED FORCING: run hil_test.py with HIL_NO_BOARD_LOCK=1 in the environment (bypasses the board lock check; do NOT release or kill the existing holder). '
|
||||
: 'If the run fails because the board lock is held (a dev session or concurrent CI job), report pass=false and set detail to start EXACTLY with "board locked:" followed by the holder JSON verbatim — never force the lock. ') +
|
||||
'Reserve the phrase "board locked" strictly for lock contention; describe a frozen or non-enumerating board as "unresponsive" instead. ' +
|
||||
`Firmware is in examples/cmake-build-${b}. Use the config for this host (hostname first), single-board flag -b ${b}, Bash timeout >= 20 min, never cancel early. ` +
|
||||
'On non-lock failures retry once with -v -r 1 (one verbose attempt for diagnosis — the first run already did the flake-retries). wedged=true if the board/fixture is unresponsive after the run (capture dmesg | tail -50 into detail).',
|
||||
{ label: `hil:${b}`, phase: 'HIL', agentType: 'hil-operator', schema: HIL },
|
||||
)
|
||||
|
||||
const results = []
|
||||
for (const b of args.boards) {
|
||||
const r = await runBoard(b)
|
||||
results.push(r || { board: b, pass: false, detail: 'hil-operator agent died', wedged: false })
|
||||
log(`${b}: ${results[results.length - 1].pass ? 'PASS' : 'FAIL'}`)
|
||||
}
|
||||
|
||||
// A concurrent CI job may have held some boards (its hil_test.py flock).
|
||||
// CI finishes a board in minutes — retry locked boards once, at the end.
|
||||
if (!args.force) {
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i].pass || !results[i].detail.startsWith('board locked')) continue
|
||||
log(`${results[i].board}: was locked — retrying once`)
|
||||
const r = await runBoard(results[i].board)
|
||||
if (r) results[i] = r
|
||||
else results[i].detail += ' (retry operator died)'
|
||||
log(`${results[i].board}: retry ${results[i].pass ? 'PASS' : 'FAIL'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const wedged = results.filter(r => r.wedged).map(r => r.board)
|
||||
if (wedged.length) log(`WEDGED boards needing usb-recover: ${wedged.join(', ')}`)
|
||||
// Workers cannot prompt the user — surface still-locked boards for the main
|
||||
// session to ask: force (re-invoke with force: true), wait, or accept.
|
||||
const locked = args.force ? [] : results.filter(r => !r.pass && r.detail.startsWith('board locked')).map(r => r.board)
|
||||
if (locked.length) log(`still locked after retry: ${locked.join(', ')} — ask the user: force / keep waiting / accept`)
|
||||
return { pass: results.every(r => r.pass), results, wedged, locked }
|
||||
226
.claude/workflows/pr-babysit.js
Normal file
226
.claude/workflows/pr-babysit.js
Normal file
@ -0,0 +1,226 @@
|
||||
export const meta = {
|
||||
name: 'pr-babysit',
|
||||
description: 'Drive a PR to green: pr-monitor triage (CI + bot reviews), port-dev fixes for validated findings, driver-reviewer verification, one commit+push per cycle',
|
||||
whenToUse: 'After opening a PR, from a checkout of the PR branch. Default is a dry run (fixes left uncommitted, nothing posted); passing autoPush: true is the explicit authorization for pushes and PR comments.',
|
||||
phases: [{ title: 'Triage' }, { title: 'Fix' }, { title: 'Verify' }, { title: 'Push' }],
|
||||
}
|
||||
|
||||
// args: { pr: number, maxCycles?: number, autoPush?: boolean (default false = dry run) }
|
||||
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
|
||||
if (!args || !args.pr) {
|
||||
throw new Error('args must be { pr: number, maxCycles?, autoPush? }; run from a checkout of the PR branch')
|
||||
}
|
||||
args.pr = Number(args.pr)
|
||||
if (!Number.isInteger(args.pr) || args.pr <= 0) {
|
||||
throw new Error('args.pr must be a positive integer PR number')
|
||||
}
|
||||
const maxCycles = args.maxCycles ?? 3
|
||||
if (!Number.isInteger(maxCycles) || maxCycles < 1) {
|
||||
throw new Error('maxCycles must be an integer >= 1')
|
||||
}
|
||||
|
||||
const TRIAGE = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['ci', 'findings', 'replies', 'done'],
|
||||
properties: {
|
||||
ci: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['status', 'infraRerun', 'realFailures'],
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['green', 'red', 'running'] },
|
||||
infraRerun: { type: 'array', items: { type: 'string' } },
|
||||
realFailures: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['check', 'firstError', 'files'],
|
||||
properties: {
|
||||
check: { type: 'string' }, firstError: { type: 'string' },
|
||||
files: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
findings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['source', 'commentId', 'file', 'line', 'claim', 'verdict', 'reason', 'fixHint'],
|
||||
properties: {
|
||||
source: { type: 'string' }, commentId: { type: 'integer' },
|
||||
file: { type: 'string' }, line: { type: 'integer' }, claim: { type: 'string' },
|
||||
verdict: { type: 'string', enum: ['valid', 'invalid', 'stale'] },
|
||||
reason: { type: 'string' }, fixHint: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
replies: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['commentId', 'body'],
|
||||
properties: { commentId: { type: 'integer' }, body: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
done: { type: 'boolean' },
|
||||
},
|
||||
}
|
||||
const DEV = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['item', 'diffstat', 'buildOk', 'board', 'notes'],
|
||||
properties: {
|
||||
item: { type: 'string' }, diffstat: { type: 'string' }, buildOk: { type: 'boolean' },
|
||||
board: { type: 'string' }, notes: { type: 'string' },
|
||||
},
|
||||
}
|
||||
const CHECK = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['addresses', 'reason'],
|
||||
properties: { addresses: { type: 'boolean' }, reason: { type: 'string' } },
|
||||
}
|
||||
const OP = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['pass', 'detail'],
|
||||
properties: { pass: { type: 'boolean' }, detail: { type: 'string' } },
|
||||
}
|
||||
|
||||
// Marking a review thread resolved has no REST endpoint — it needs the
|
||||
// GraphQL resolveReviewThread mutation. Shared recipe handed to the posting
|
||||
// agents so a fixed/refuted comment ends up both answered AND resolved.
|
||||
const RESOLVE_RECIPE =
|
||||
'To resolve the review thread for an inline review comment (its integer databaseId is the commentId): ' +
|
||||
'get owner/repo via `gh repo view --json nameWithOwner -q .nameWithOwner`; find the thread node id with ' +
|
||||
'`gh api graphql -f query=\'query($o:String!,$r:String!,$p:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$p){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:50){nodes{databaseId}}}}}}}\' -F o=OWNER -F r=REPO -F p=' + args.pr + '` ' +
|
||||
'(while hasNextPage is true and the comment is not found yet, re-run with -F c=<endCursor>), pick the thread whose comments contain that databaseId, then resolve it with ' +
|
||||
'`gh api graphql -f query=\'mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}\' -F id=THREAD_ID`. ' +
|
||||
'Issue comments (the 404 fallback case) have no thread — do not try to resolve those.'
|
||||
|
||||
// Mechanical reply skeleton shared by the refuted-replies and fixed-resolve
|
||||
// steps — kept in one place because the two copies drifted once already
|
||||
// (the 404 fallback was missing from one of them).
|
||||
const postReplyRecipe = (noun) =>
|
||||
`post a threaded reply to its inline comment via gh api repos/{owner}/{repo}/pulls/${args.pr}/comments/{commentId}/replies -f body=<body> ` +
|
||||
'(valid for inline review comments); if that 404s, the id is an issue comment — post a regular PR comment instead ' +
|
||||
`(gh pr comment ${args.pr} --body <quote the original point, then the ${noun}>) and skip resolving. ` +
|
||||
`After replying to an inline comment, mark its thread resolved. ${RESOLVE_RECIPE} `
|
||||
|
||||
const history = []
|
||||
const repliedIds = new Set() // issue comments can't be thread-resolved, so they re-harvest every cycle — never reply twice
|
||||
for (let cycle = 1; cycle <= maxCycles; cycle++) {
|
||||
const t = await agent(
|
||||
`Triage PR #${args.pr}. If checks are still running, wait for them first (gh pr checks ${args.pr} --watch, Bash timeout >= 30 min). ` +
|
||||
'Then follow your triage procedure: classify CI failures, re-run infra ones, harvest and adversarially validate bot review findings, draft replies for invalid/stale ones.',
|
||||
{ label: `triage#${cycle}`, phase: 'Triage', agentType: 'pr-monitor', schema: TRIAGE },
|
||||
)
|
||||
if (!t) {
|
||||
history.push({ cycle, error: 'pr-monitor died' })
|
||||
return { pass: false, cycles: cycle, history, reason: 'pr-monitor-died' }
|
||||
}
|
||||
const entry = { cycle, triage: t }
|
||||
history.push(entry)
|
||||
|
||||
// Post drafted replies to REFUTED findings as soon as triage produces them —
|
||||
// decoupled from fixing/pushing so done/unactionable cycles still post.
|
||||
// Reply AND resolve the thread. Outward-facing, so gated on autoPush.
|
||||
const freshReplies = t.replies.filter(r => !repliedIds.has(r.commentId))
|
||||
if (freshReplies.length > 0 && args.autoPush === true) {
|
||||
const posted = await agent(
|
||||
`Reply to and resolve these refuted review comments on PR #${args.pr}. For each: ${postReplyRecipe('reply')}` +
|
||||
`Replies: ${JSON.stringify(freshReplies)}. pass=true only if every reply was posted and every inline thread resolved; detail = what went where.`,
|
||||
{ label: `replies#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
|
||||
)
|
||||
// attempted counts as replied: better to drop a failed reply than spam duplicates
|
||||
freshReplies.forEach(r => repliedIds.add(r.commentId))
|
||||
if (!posted || !posted.pass) log(`cycle ${cycle}: refuted reply/resolve incomplete — ${posted ? posted.detail : 'agent died'}`)
|
||||
}
|
||||
|
||||
if (t.done) {
|
||||
log(`cycle ${cycle}: PR is green with no unresolved valid findings`)
|
||||
return { pass: true, cycles: cycle, history }
|
||||
}
|
||||
|
||||
// Group actionable work by top-level scope (plain JS — no model tokens).
|
||||
const groups = new Map()
|
||||
const groupOf = (key) => {
|
||||
if (!groups.has(key)) groups.set(key, { key, files: new Set(), notes: [] })
|
||||
return groups.get(key)
|
||||
}
|
||||
for (const f of t.findings.filter(x => x.verdict === 'valid')) {
|
||||
const g = groupOf(f.file.split('/').slice(0, 3).join('/'))
|
||||
g.files.add(f.file)
|
||||
g.notes.push(`${f.file}:${f.line} [${f.source}] ${f.claim} — hint: ${f.fixHint}`)
|
||||
}
|
||||
for (const rf of t.ci.realFailures) {
|
||||
const g = groupOf((rf.files[0] || rf.check).split('/').slice(0, 3).join('/'))
|
||||
rf.files.forEach(x => g.files.add(x))
|
||||
g.notes.push(`CI ${rf.check}: ${rf.firstError}`)
|
||||
}
|
||||
const work = [...groups.values()]
|
||||
|
||||
if (work.length === 0) {
|
||||
if (t.ci.status === 'running' || t.ci.infraRerun.length > 0) {
|
||||
log(`cycle ${cycle}: only infra re-runs in flight — next cycle waits on them`)
|
||||
continue
|
||||
}
|
||||
log(`cycle ${cycle}: nothing actionable`)
|
||||
return { pass: false, cycles: cycle, history, reason: 'unactionable' }
|
||||
}
|
||||
|
||||
const fixes = await pipeline(
|
||||
work,
|
||||
w => agent(
|
||||
`Fix the following issues on the current PR branch (the working tree IS the PR checkout).\n` +
|
||||
`Scope: ${[...w.files].join(', ')}\nIssues:\n- ${w.notes.join('\n- ')}`,
|
||||
{ label: `fix:${w.key}`, phase: 'Fix', agentType: 'port-dev', effort: 'xhigh', schema: DEV },
|
||||
),
|
||||
(fix, w) => fix && agent(
|
||||
`Verify the uncommitted changes for ${[...w.files].join(', ')} (use git diff -- <files>, and read any newly created untracked files directly) address these issues:\n- ${w.notes.join('\n- ')}\n` +
|
||||
'Return {"addresses": bool, "reason": string}.',
|
||||
{ label: `check:${w.key}`, phase: 'Verify', agentType: 'driver-reviewer', effort: 'xhigh', schema: CHECK },
|
||||
).then(v => ({ ...fix, addresses: !!(v && v.addresses), checkReason: v ? v.reason : 'verifier died' })),
|
||||
)
|
||||
const aliveFixes = fixes.filter(Boolean)
|
||||
if (aliveFixes.length < work.length) log(`${work.length - aliveFixes.length} fix group(s) lost to dead workers`)
|
||||
entry.fixes = aliveFixes
|
||||
|
||||
if (args.autoPush !== true) {
|
||||
log('autoPush not set: fixes left uncommitted in the working tree (dry run)')
|
||||
return { pass: false, cycles: cycle, history, dryRun: true }
|
||||
}
|
||||
|
||||
// Verification gates the push: never push a cycle containing an unverified
|
||||
// fix or the partial edits of a dead worker.
|
||||
const unverified = aliveFixes.filter(f => f.addresses !== true)
|
||||
if (aliveFixes.length < work.length || unverified.length > 0) {
|
||||
for (const f of unverified) log(`fix for ${f.item}: failed verification — ${f.checkReason}`)
|
||||
log(`cycle ${cycle}: fixes left uncommitted for human review — not pushing unverified changes`)
|
||||
return { pass: false, cycles: cycle, history, reason: 'fix-verification-failed' }
|
||||
}
|
||||
|
||||
const push = await agent(
|
||||
`On the current PR branch: commit ALL working-tree changes as ONE commit (imperative message summarizing the cycle-${cycle} fixes for PR #${args.pr}, repo commit conventions), ` +
|
||||
"then push to the PR's remote branch. pass=true only if commit AND push succeeded; detail = pushed SHA.",
|
||||
{ label: `push#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
|
||||
)
|
||||
if (!push || !push.pass) {
|
||||
log(`cycle ${cycle}: push failed — stopping`)
|
||||
return { pass: false, cycles: cycle, history, reason: 'push-failed' }
|
||||
}
|
||||
|
||||
// The valid bot findings were fixed and pushed — answer each inline comment
|
||||
// with what changed and resolve its thread. CI-failure work has no comment.
|
||||
const fixed = t.findings.filter(x => x.verdict === 'valid')
|
||||
if (fixed.length > 0) {
|
||||
const resolved = await agent(
|
||||
`The fixes for PR #${args.pr}'s valid review findings were just committed and pushed (${push.detail}). ` +
|
||||
`For each finding below: ${postReplyRecipe('fix note')}` +
|
||||
'Each reply states the finding is fixed in the pushed commit, with one line on the change. ' +
|
||||
`Findings: ${JSON.stringify(fixed.map(f => ({ commentId: f.commentId, file: f.file, line: f.line, claim: f.claim, fixHint: f.fixHint })))}. ` +
|
||||
'pass=true only if every reply was posted and every thread resolved; detail = what went where.',
|
||||
{ label: `resolve#${cycle}`, phase: 'Push', model: 'sonnet', schema: OP },
|
||||
)
|
||||
if (!resolved || !resolved.pass) log(`cycle ${cycle}: fixed reply/resolve incomplete — ${resolved ? resolved.detail : 'agent died'}`)
|
||||
}
|
||||
}
|
||||
return { pass: false, cycles: maxCycles, history, reason: 'maxCycles reached' }
|
||||
100
.claude/workflows/validate.js
Normal file
100
.claude/workflows/validate.js
Normal file
@ -0,0 +1,100 @@
|
||||
export const meta = {
|
||||
name: 'validate',
|
||||
description: 'Pre-PR software validation: unit tests + per-board build sweeps + code-size compare + PVS, in parallel, joined into one verdict',
|
||||
whenToUse: 'Before opening or updating a PR, after any non-trivial change',
|
||||
phases: [{ title: 'Validate', detail: 'unit + builds + size + pvs in parallel' }],
|
||||
}
|
||||
|
||||
// args: { boards: string[], examples?: string, base?: string, skip?: ('unit'|'size'|'pvs')[] }
|
||||
if (typeof args === 'string') { try { args = JSON.parse(args) } catch { /* not JSON: shape check below reports it */ } }
|
||||
if (!args || !Array.isArray(args.boards) || args.boards.length === 0) {
|
||||
throw new Error('args must be { boards: string[], examples?, base?, skip? }')
|
||||
}
|
||||
const skip = args.skip || []
|
||||
for (const s of skip) log(`stage skipped by request: ${s}`)
|
||||
const base = args.base || 'master'
|
||||
const clip = (s, n = 800) =>
|
||||
s.length > n ? s.slice(0, n) + ` …[truncated ${s.length - n} chars]` : s
|
||||
|
||||
const STAGE = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['pass', 'detail'],
|
||||
properties: { pass: { type: 'boolean' }, detail: { type: 'string' } },
|
||||
}
|
||||
const BUILD = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['board', 'pass', 'builtCount', 'failures'],
|
||||
properties: {
|
||||
board: { type: 'string' }, pass: { type: 'boolean' }, builtCount: { type: 'integer' },
|
||||
failures: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['example', 'class', 'firstError'],
|
||||
properties: { example: { type: 'string' }, class: { type: 'string' }, firstError: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const PVS = {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['pass', 'ga1', 'ga2', 'changedFindings', 'detail'],
|
||||
properties: {
|
||||
pass: { type: 'boolean' }, ga1: { type: 'integer' }, ga2: { type: 'integer' },
|
||||
changedFindings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object', additionalProperties: false,
|
||||
required: ['file', 'line', 'rule', 'level', 'message'],
|
||||
properties: {
|
||||
file: { type: 'string' }, line: { type: 'integer' }, rule: { type: 'string' },
|
||||
level: { type: 'integer' }, message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
detail: { type: 'string' },
|
||||
},
|
||||
}
|
||||
|
||||
const thunks = []
|
||||
|
||||
if (!skip.includes('unit')) thunks.push(() =>
|
||||
agent(
|
||||
'Run the TinyUSB unit tests: cd test/unit-test && ceedling test:all. ' +
|
||||
'pass=true only if every test passes. detail = the ceedling summary line, or the first failing test output.',
|
||||
{ label: 'unit', phase: 'Validate', model: 'haiku', schema: STAGE },
|
||||
).then(r => r && { stage: 'unit', ...r }))
|
||||
|
||||
for (const b of args.boards) thunks.push(() =>
|
||||
agent(
|
||||
`Build TinyUSB examples for board ${b}` + (args.examples ? ` (only: ${args.examples})` : ' (full example set)') + '.',
|
||||
{ label: `build:${b}`, phase: 'Validate', agentType: 'builder', schema: BUILD },
|
||||
).then(r => r && {
|
||||
stage: `build:${b}`, pass: r.pass,
|
||||
detail: r.pass ? `${r.builtCount} examples built` : clip(JSON.stringify(r.failures)),
|
||||
}))
|
||||
|
||||
if (!skip.includes('size')) thunks.push(() =>
|
||||
agent(
|
||||
`Compare TinyUSB code size against ${base}: python3 tools/metrics_compare_base.py --base-branch ${base} -b ${args.boards[0]} -e device/cdc_msc (exactly this command — no extra positional args). ` +
|
||||
'The report lands in cmake-metrics/<board>/metrics_compare.md. pass=false only if the tool itself errors; ' +
|
||||
'detail = the flash/RAM delta summary from the report (mention any example that grew).',
|
||||
{ label: 'size', phase: 'Validate', model: 'haiku', schema: STAGE },
|
||||
).then(r => r && { stage: 'size', ...r }))
|
||||
|
||||
if (!skip.includes('pvs')) thunks.push(() =>
|
||||
agent(
|
||||
`Run PVS-Studio static analysis for board ${args.boards[0]}, gating on files changed vs ${base}. ` +
|
||||
'Parallel build agents are running — use your dedicated build dir, never cmake-build-<board>.',
|
||||
{ label: 'pvs', phase: 'Validate', agentType: 'static-analyzer', effort: 'low', schema: PVS },
|
||||
).then(r => r && {
|
||||
stage: 'pvs', pass: r.pass,
|
||||
detail: r.pass ? r.detail : clip(`${r.detail} ${JSON.stringify(r.changedFindings)}`),
|
||||
}))
|
||||
|
||||
const results = (await parallel(thunks)).filter(Boolean)
|
||||
const dead = thunks.length - results.length
|
||||
if (dead > 0) log(`${dead} stage agent(s) died — counted as failures`)
|
||||
const failures = results.filter(r => !r.pass)
|
||||
log(`${results.length}/${thunks.length} stages completed, ${failures.length} failing`)
|
||||
return { pass: failures.length === 0 && dead === 0, stages: results, failures }
|
||||
@ -12,6 +12,7 @@ Bias toward caution over speed. For trivial tasks, use judgment.
|
||||
- **Simplicity** — no features, abstractions, flexibility, or error handling beyond what was asked. If 200 lines could be 50, rewrite.
|
||||
- **Surgical changes** — touch only what the task requires; match existing style; don't refactor working code; mention unrelated dead code rather than deleting it. Remove only orphans *your* changes created.
|
||||
- **Goal-driven** — turn tasks into verifiable goals ("write failing test, make it pass"). For multi-step work, state a brief `step → verify` plan.
|
||||
- **Worktrees** — default to a git worktree (`git worktree add`) for any branch or multi-step work; never switch the shared primary checkout's branch. Sessions run concurrently: switching the primary checkout mid-flight disrupts other sessions and can silently point a review, build, or commit at the wrong diff. Only trivial one-shot fixes may skip this.
|
||||
|
||||
## Ground Rules
|
||||
|
||||
|
||||
1693
docs/superpowers/plans/2026-07-09-claude-agents-workflows.md
Normal file
1693
docs/superpowers/plans/2026-07-09-claude-agents-workflows.md
Normal file
File diff suppressed because it is too large
Load Diff
78
docs/superpowers/plans/2026-07-09-smoke-results.md
Normal file
78
docs/superpowers/plans/2026-07-09-smoke-results.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Smoke Test Results — multi-agent dev/test harness
|
||||
|
||||
Date: 2026-07-09 (evening session)
|
||||
|
||||
## Task 13 — validate workflow (run wf_4f10863f-6ff, 2 boards)
|
||||
|
||||
| Stage | Result | Evidence |
|
||||
|---|---|---|
|
||||
| unit | PASS | ceedling 61/61, 2.72 s |
|
||||
| size | PASS | stm32f407disco device/cdc_msc vs master: TOTAL 14167 B, +0.0% every file (`cmake-metrics/stm32f407disco/metrics_compare_device_cdc_msc.md`) |
|
||||
| pvs | pass=false — **gate working as specified** | GA:1=20 / GA:2=36; flags 5 GA:1 in files changed vs local `master` (midi2_device.c:245 V547 branch-new upstream; usbd.c V763 x3 + usbh.c:2109 V1008 pre-existing lines in touched files). Root cause of "changed" set: local `master` lags `origin/master` (upstream MIDI2 merge), so upstream churn counts as changed. Logic per spec. |
|
||||
| build:stm32f407disco | BLOCKED (env) | `agent type 'builder' not found` — see registry note below |
|
||||
| build:raspberry_pi_pico | BLOCKED (env) | same |
|
||||
|
||||
Resume: `Workflow({scriptPath: '<worktree>/.claude/workflows/validate.js', resumeFromRunId: 'wf_4f10863f-6ff', args: {boards: [...]}})` — unit/size/pvs replay from cache.
|
||||
|
||||
## Task 15 — driver-review workflow (run wf_aa86dddc-11f) — PASS
|
||||
|
||||
- 2 dirs x 1 question (unbounded busy-waits), 22 agents (2 scanners + 20 verifiers), 0 errors, ~494k worker tokens.
|
||||
- rusb2: 6 confirmed findings — the FRDY spin `dcd_rusb2.c:126` (matches the known hardware wedge) + CURPIPE spins (125, 249, 292, 477, ...) + CFIFO ISEL spin (347), each adversarially verified against code; one verifier corrected a scanner's claim that the Renesas manuals were missing from calibre (RA6M5/RX65N are present).
|
||||
- Verification layer demonstrably filters and grades (severity/confidence preserved, refutation reasoning recorded).
|
||||
|
||||
## Harness facts discovered (affect all future sessions)
|
||||
|
||||
1. **Custom agent types register at session start, from the LAUNCH directory's `.claude/agents/`** — not the worktree's, and not on file changes mid-session. Runtime copies were mirrored to `/home/hathach/code/tinyusb/.claude/agents/` (untracked); a NEW session is required for builder/port-dev/hil-operator to resolve.
|
||||
2. **Workflow `args` arrives as a JSON string** — all six scripts normalize with `if (typeof args === 'string') args = JSON.parse(args)`.
|
||||
3. **Workflow-by-name resolution can serve a stale cached script** — invoke via `scriptPath` when iterating.
|
||||
|
||||
## Task 13 completion — PASS
|
||||
|
||||
Resumed run wf_4f10863f-6ff once agents registered: build:stm32f407disco 43 examples, build:raspberry_pi_pico 43 examples, both PASS; verdict mechanics correct (pass=false only from the pvs stage's stale-local-master base; re-run vs origin/master in Task 17 is fully green).
|
||||
|
||||
## Task 14 — fanout-dev — PASS (wf_d2fcc566-ebc)
|
||||
|
||||
2 port-dev workers (rp2040, stm32_fsdev): exactly 1-line diffs, `git clang-format` clean, both independently build-verified (`verifyBuild: true`), deps self-healed (get_deps stm32f0; PICO_SDK_PATH). Smoke edits reverted; tree clean.
|
||||
|
||||
## Task 16 — hil-validate + board locks — PASS mechanics (real rig)
|
||||
|
||||
- Lock-conflict run (wf_cab1ac1d-c4c): failed in 48 s, `detail` = `board locked: {holder JSON}` verbatim, `locked: ['raspberry_pi_pico']`, retry attempted, no flash.
|
||||
- Force run (wf_d079a33e-dd8): flashed+tested with HIL_NO_BOARD_LOCK=1, holder pid survived (bypass, not theft), `locked: []`.
|
||||
- Normal run (wf_87ec3c8f-e87): self-locking path live, board flashed/booted 18x, no lock messages.
|
||||
- actions-runner `active` the entire time; svc.sh never touched.
|
||||
- RIG FINDING (not this branch): pico PIO-USB host-port fixture devices (1a86_7523 CDC, 048d_04d2 MSC) not enumerating — 5 host-mode tests fail identically across runs; 13 device-mode tests all pass; firmware exonerated.
|
||||
|
||||
## Task 17 — /pre-pr end-to-end — PASS (wf_8a421ef1-fcf)
|
||||
|
||||
BASE=origin/master (local master stale). No C changes → minimal path: software-only, boards=[stm32f407disco]. Verdict `pass: true`: unit 61/61, 43 examples, size +0.0%, pvs green (no C diffs). HIL correctly skipped.
|
||||
|
||||
## Task 18 — pr-babysit dry + pr-monitor triage — launched
|
||||
|
||||
pr-babysit {pr: 3761, maxCycles: 1, autoPush: false} (wf_361c9e0a-d0e) + direct pr-monitor triage of PR 3750 (3 Copilot rounds) from a /tmp checkout of the PR head. Results recorded when complete.
|
||||
|
||||
## Post-smoke revisions
|
||||
|
||||
- Model tiering (owner): builder→haiku, hil-operator/pr-monitor→sonnet, unit/size→haiku, pvs/push/replies→sonnet; port-dev/driver-reviewer stay opus xhigh.
|
||||
- driver-reviewer now checks MCU errata sheets; missing erratum workarounds are findings.
|
||||
- Remaining: Task 18 verdicts, final whole-branch review, memory note update for the lock protocol.
|
||||
|
||||
Stop-gate extra (done this session): board_lock `cmd_hold` holder-signaled success via pipe (c326eaacc), storm-tested 10/10 exactly-one-winner.
|
||||
|
||||
## Post-review fixes (2026-07-10, owner-confirmed batch)
|
||||
|
||||
- board_lock: holder daemon detaches stdio (captured `hold` returned in 30 ms; pre-fix hung on the inherited pipe); `is_locked()` re-done as pid-liveness probe — never touches the flock, so status/pre-check storms can no longer fail a concurrent acquirer (0/15 failures under a 300-probe storm; hold storm still 1-winner-in-10).
|
||||
- hil_test: locked board now emits a visible `board-locked` ❌ report row (report matches exit code); `accumulate_report` clears the stale marker once the board runs for real (3-scenario logic test green). failed-tests stays empty so re-runs repeat the whole board.
|
||||
- pr-babysit: `autoPush` now opt-in (default dry run; `autoPush: true` is the explicit push/comment authorization); RESOLVE_RECIPE paginates reviewThreads (`pageInfo` + cursor); post-push resolve step gained the same issue-comment 404 fallback as the refuted-replies step.
|
||||
- validate: size stage passes `--base-branch <base>`; pvs stage now calls the new `static-analyzer` agent (sonnet, structured `{pass, ga1, ga2, changedFindings[], detail}`).
|
||||
- NEW agent `static-analyzer` (PVS-Studio SAST+MISRA, read-only) — mirrored to the launch-dir registry; registers next session (harness fact 1), so the validate pvs stage is unsmokable until then.
|
||||
|
||||
## Max-effort review fixes (2026-07-13, /code-review opus max: 10 finders → 28 verifiers → sweep)
|
||||
|
||||
- validate.js size stage: dropped the stray trailing `.` that made metrics_compare_base.py exit 2 on every run (regression from the 2026-07-10 batch).
|
||||
- board_lock: flock is now the sole authority — cmd_hold's pid-liveness pre-gate removed (a live-but-moved-on hil_test.py worker pid no longer blocks a free board; verified: hold succeeds over a stale live-pid record, storm still 1-winner-in-10). cmd_release probes the flock before acting: free → clear stale record only; held by `hil_test.py` → refuse (CI mid-test, holder survives — verified); held otherwise → SIGTERM with PermissionError handled. Holder daemon truncates records on SIGTERM; success pipe dup'd above fd 2 (closed-stdio hold now succeeds — was orphan-holder + false failure, repro'd both ways).
|
||||
- hil_test: lock record truncated on per-board release (pool workers outlive flocks); fail-open on OSError now prints a warning; unknown `-b` names exit 1 instead of a silent zero-test green (was exploitable as a false HIL pass through hil-validate); accumulate_report deletes an emptied board row (variant boards no longer leave a blank ghost row — 4-scenario test green).
|
||||
- port-dev.md: `git clang-format -- <files>` scoped to the worker's own files (bare invocation reformatted concurrent siblings' edits in shared checkouts).
|
||||
- pr-babysit: reply skeleton factored into postReplyRecipe (the two copies had already drifted once); cross-cycle repliedIds dedup (issue-comment refutations were re-posted every cycle); args.pr integer + maxCycles >= 1 validation.
|
||||
- All 6 workflows: JSON.parse(args) wrapped so a non-JSON string hits the friendly shape error; driver-review throws on empty dimensions ([] is truthy); hil-validate dead length-clause dropped; retry instruction now `-v -r 1` (diagnosis, not 3 more flake-retries).
|
||||
- Plan doc header replaced with a DO-NOT-EXECUTE historical banner + all 74 boxes checked (re-execution would have recreated pre-static-analyzer files with hooks disabled).
|
||||
- Refuted by verification (left as-is by design): dry-run verify spawns (consumed via history), per-finding verifiers, 4-dim scanners, fanout double-build, validate parallel triple-compile, release --all / hold --config, board_lock import into hil_test (hil_ci.sh ships hil_test.py alone), lock exit code (JSON sidecar already carries board-locked), check.sh scope, effort scatter (harness-forced), local fcntl import (Windows guard).
|
||||
@ -0,0 +1,172 @@
|
||||
# Multi-Agent Dev/Test Setup for TinyUSB — Design
|
||||
|
||||
Date: 2026-07-09
|
||||
Branch: worktree-claude-agents-workflows
|
||||
|
||||
## Goal
|
||||
|
||||
Give Claude Code sessions in this repo a reusable, efficient multi-agent harness
|
||||
for developing and testing TinyUSB: custom worker agents that already know the
|
||||
repo's build/test/rig discipline, and small deterministic workflows that fan
|
||||
them out. The orchestrator (main session) authors arguments and reads verdicts;
|
||||
workers do the volume.
|
||||
|
||||
## Context
|
||||
|
||||
- Existing process skills: `hil`, `code-size`, `pvs`, `build-doc`, `usbmon`,
|
||||
`usb-debug`, `usb-recover`, `make-release` (`.claude/skills/`).
|
||||
- One prototype workflow exists in the master working tree (untracked):
|
||||
`.claude/workflows/port-audit.js`. This design supersedes it.
|
||||
- No custom agent definitions exist yet (`.claude/agents/` absent).
|
||||
- Test infra: `test/unit-test` (ceedling), `test/hil` (`hil_test.py`,
|
||||
`tinyusb.json`), `test/fuzz`; size metrics via
|
||||
`tools/metrics_compare_base.py`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Layered: **agents** (who does the work, with baked-in domain knowledge) ×
|
||||
**workflows** (deterministic fan-out/join) × **one skill** (human entry point).
|
||||
|
||||
### Worker agents — `.claude/agents/*.md`
|
||||
|
||||
Tiered models (owner revision 2026-07-09; originally all-opus): `port-dev`
|
||||
and `driver-reviewer` on **opus** at **xhigh**; `hil-operator`, `pr-monitor`
|
||||
and `static-analyzer` on **sonnet**; `builder` on **haiku** (mechanical,
|
||||
log-heavy).
|
||||
|
||||
| Agent | Effort | Role |
|
||||
|---|---|---|
|
||||
| `builder` | low | Build one board's example set with the canonical commands: `cmake -B cmake-build-<board> -DBOARD=<board> -G Ninja -DCMAKE_BUILD_TYPE=MinSizeRel` from `examples/` (exact dir name — HIL expects it), `python3 tools/get_deps.py` on missing deps, `. $HOME/code/esp-idf/export.sh` for Espressif boards, tolerate non-critical objcopy failures. Returns structured `{board, pass, failures: [{example, firstError}]}`. |
|
||||
| `port-dev` | xhigh | Implement one well-scoped change in one port / file set. Follows repo rules: C99, 2-space indent, snake_case, `TU_ASSERT`, no dynamic allocation, ISR work deferred to task context. Runs `clang-format` (repo `.clang-format`) on touched files before finishing. Cross-checks the MCU datasheet in `$HOME/Documents/calibre-library` when changing dcd/hcd register logic. Verifies with a targeted build of one board using the port. Returns `{item, diffstat, buildOk, notes}`. |
|
||||
| `driver-reviewer` | xhigh | Review one dcd/hcd directory against dimensions: correctness, ISR safety, register use vs. datasheet AND MCU errata (calibre library; missing erratum workarounds are findings), style. Returns structured findings `{file, line, snippet, why, severity, confidence}` — coverage-first (report everything; filtering happens downstream). |
|
||||
| `hil-operator` | default | All rig interaction — the actions-runner service is NEVER stopped; per-board flock locks arbitrate with concurrent CI. `hil_test.py` runs rely on its per-board self-locking; manual hardware work (JLink/GDB, usbtest, serial) is wrapped in `test/hil/board_lock.py hold/release`; rig-wide ops (uhubctl, pci-rebind) require `hold --all`; on wedge `usb_recover.sh` + dmesg. Used strictly serially — never two instances concurrently. |
|
||||
| `pr-monitor` | default | Triage one GitHub PR via `gh`: check CI status (`gh pr checks`), read failing run logs and classify each failure infra/flake vs real; re-run infra failures (`gh run rerun --failed`); harvest automated review comments (Codex/Copilot/Claude bots — knows their signals: Codex posts a "Didn't find any major issues" issue comment when clean; Copilot drops out of `requested_reviewers` when done; bot logins differ across APIs); adversarially validate each finding against the actual code. Returns structured triage `{ci: {status, infraRerun[], realFailures[]}, findings: [{source, file, line, claim, verdict, fixHint}]}`. Read/triage/re-run/reply only — never edits code. |
|
||||
| `static-analyzer` | low | Run PVS-Studio (SAST + MISRA C:2023/C++:2008) for one board: build with exported `compile_commands.json` (via `run_pvs.sh` solo, or a dedicated `cmake-build-pvs` dir when parallel builders run), analyze against `.PVS-Studio/.pvsconfig`, gate on diagnostics in files changed vs a base ref. Returns `{pass, ga1, ga2, changedFindings[], detail}`; `pass=false` only on GA:1 in changed files or tool failure. Read-only. |
|
||||
|
||||
### Workflows — `.claude/workflows/*.js`
|
||||
|
||||
| Workflow | Args | Shape |
|
||||
|---|---|---|
|
||||
| `validate.js` | `{boards[], examples?, base?, skip?: ('unit'\|'size'\|'pvs')[]}` | One parallel stage: unit tests (ceedling) + one `builder` per board + code-size compare (`tools/metrics_compare_base.py` vs `base`, default master) + PVS analyze (`static-analyzer` agent). Join → plain-JS verdict `{pass, failures[]}`. Barrier is correct here: the verdict needs all results. |
|
||||
| `fanout-dev.js` | `{task, items[], board?, review?, worktree?}` | `pipeline(items)`: `port-dev` per item → `builder` verify → optional `driver-reviewer` pass. Workers share the tree by default (ports are disjoint directories); `worktree: true` switches on per-agent worktree isolation for collision-prone tasks. Returns per-item results. |
|
||||
| `driver-review.js` | `{dirs[], dimensions?, question?}` | Supersedes `port-audit.js`. Scan stage per (dir × dimension) → adversarial verify per finding (verifier prompted to refute) → confirmed findings only. |
|
||||
| `hil-validate.js` | `{boards[], force?}` | Strictly serial `for` loop of `hil-operator` calls; each board is protected by `hil_test.py`'s own per-board flock, so the actions-runner keeps running throughout. Boards found locked (a concurrent CI job mid-test) are retried once at the end of the loop; boards still locked are returned in `locked[]` for a user force/wait/accept decision. `force: true` (user-authorized only) bypasses locks via `HIL_NO_BOARD_LOCK=1`. Returns per-board `{board, pass, detail}` plus `wedged[]` and `locked[]`. |
|
||||
| `full-check.js` | `{boards[], ...}` | Thin composer: `workflow('validate', ...)` → only if green → `workflow('hil-validate', ...)`. Single nesting level (children do not nest further). |
|
||||
| `pr-babysit.js` | `{pr, maxCycles?, autoPush?}` | Cycle until CI green + review threads resolved, or `maxCycles` (default 3): `pr-monitor` triage (blocks on `gh pr checks --watch` while CI runs) → valid findings + real CI failures grouped by file/port → `port-dev` fix per group (pipeline) → `driver-reviewer` verifies each fix addresses its finding → one commit + push per cycle. Every actioned inline comment is both **replied to and marked resolved** (GraphQL `resolveReviewThread`): refuted findings get the refutation, fixed findings get a "fixed in <sha>" note. `autoPush` defaults **false** (dry run: fixes stay uncommitted, nothing posted); **passing `autoPush: true` is the explicit push authorization** for follow-up commits and PR comments on that branch (scoped exception to the hold-pushes-until-told rule). |
|
||||
|
||||
### Board lock protocol — `test/hil/` (repo code)
|
||||
|
||||
CI and dev sessions share the rig concurrently; the actions-runner service is
|
||||
never stopped. Arbitration is per-board kernel flocks in
|
||||
`/tmp/tinyusb-hil-locks/<board>.lock` — auto-released when the holder process
|
||||
dies, with holders truncating their lock-file record on release so records
|
||||
stay truthful (`/tmp` clears on reboot):
|
||||
|
||||
- **`test/hil/board_lock.py`** (new tool): `hold <boards|--all> --reason TEXT`
|
||||
spawns a background holder process flocking each board file (JSON
|
||||
`{pid, reason, since}` written inside for debuggability); the holder's own
|
||||
LOCK_NB flock is the sole authority — there is deliberately no pid-based
|
||||
pre-check (recorded pids can be stale or recycled). `release <boards|--all>`
|
||||
probes each board's flock: a free lock only gets its stale record cleared;
|
||||
a genuinely held one gets its recorded holder SIGTERMed — unless the holder
|
||||
reason is `hil_test.py` (a CI run mid-test), which release refuses to kill.
|
||||
`status` lists holders. `--all` is required before rig-wide operations
|
||||
(uhubctl power cycling, pci-rebind — bus renumbering affects every board).
|
||||
- **`hil_test.py` guard** (small patch to the per-board worker): take the
|
||||
board's flock non-blocking before flashing and hold it for that board's
|
||||
flash+test; on acquire it writes its own holder info
|
||||
(`{pid, reason: "hil_test.py", since}`) so conflicts report truthfully in
|
||||
both directions, and truncates that record on release (the pool worker
|
||||
outlives the per-board flock). If already held, FAIL the board immediately —
|
||||
`Failed: board locked: <holder info>` — no flash, no waiting.
|
||||
The CI job fails visibly for exactly those boards and `re-run failed`
|
||||
passes once the lock is released (`build.yml` already retries the HIL step
|
||||
once, absorbing short dev sessions). Guard proceeds unlocked — with a
|
||||
printed warning — if the lock dir is unusable, and `-b` names absent from
|
||||
the config are a hard error rather than a silent zero-test green run.
|
||||
- **Re-entrancy rule:** dev sessions do NOT pre-hold boards they are about to
|
||||
run `hil_test.py` on (it self-locks; pre-holding deadlocks it).
|
||||
`board_lock.py hold` is for hardware work outside `hil_test.py` only.
|
||||
- **Symmetric conflicts (CI running while an agent tests):** CI mid-test on a
|
||||
board holds that board's flock, so the dev side hits it — `board_lock.py
|
||||
hold` refuses showing the holder, and a dev `hil_test.py` run fails that
|
||||
board fast. Two sessions can never double-flash a board.
|
||||
`hil-validate.js` retries locked boards once at the end of its loop (CI
|
||||
finishes a board in minutes); manual sessions wait and retry when the
|
||||
holder reason is `hil_test.py`. Concurrent activity on *different* boards
|
||||
is normal — CI's own `hil_test.py` already runs boards in parallel via
|
||||
`multiprocessing.Pool`.
|
||||
- **User decision on persistent locks:** workers cannot prompt the user, so
|
||||
boards still locked after the retry are returned in `locked[]`; the main
|
||||
session then asks the user — **force** (re-invoke `hil-validate` with
|
||||
`force: true`, which runs `hil_test.py` with `HIL_NO_BOARD_LOCK=1`: a
|
||||
bypass, never killing the holder, at the user-accepted risk of colliding
|
||||
with a mid-test CI job), **continue waiting** (re-invoke later), or
|
||||
**accept** the partial result. Workers never force on their own; forcing
|
||||
requires the user's explicit authorization relayed in the prompt.
|
||||
- **Propagation caveat:** CI enforces the guard only once the patch lands on
|
||||
master (CI runs `hil_test.py` from each PR's merge-with-master ref). This
|
||||
also widens blast radius beyond `.claude/` config into shared CI test
|
||||
infra — the change is a small isolated guard, but it needs its own CI pass
|
||||
and careful review on the eventual PR.
|
||||
|
||||
### Entry point — `.claude/skills/pre-pr/SKILL.md`
|
||||
|
||||
`/pre-pr` instructs the session to: scout the diff inline (cheap), map changed
|
||||
`src/portable/<vendor>/<ip>` and `src/class/*` to affected families and pick
|
||||
test boards from `hw/bsp` (fallback: `stm32f407disco`, `raspberry_pi_pico`),
|
||||
launch `full-check` with that board list, and summarize the verdict. Markdown
|
||||
carries the judgment; JS carries the orchestration.
|
||||
|
||||
## Model & effort policy
|
||||
|
||||
- Tiered worker models: `port-dev`/`driver-reviewer` **opus** `xhigh`;
|
||||
`hil-operator`/`pr-monitor` **sonnet**; `builder` **haiku**.
|
||||
- Inline workflow stages: unit/size **haiku**; pvs **sonnet** (low effort);
|
||||
pr-babysit push/replies **sonnet**.
|
||||
- Agent frontmatter `model:` is canonical for `agentType` calls; it is read
|
||||
at session-start registration, so tier changes apply from the next session.
|
||||
- Deterministic control flow (loops, joins, filtering, verdict assembly) is
|
||||
plain JS in the workflow scripts — zero model tokens.
|
||||
- The orchestrating session derives work lists inline (glob/grep) and passes
|
||||
them as `args`; worker prompts carry paths, not file contents.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Workers always return schema-validated structured output; validation retries
|
||||
happen at the tool-call layer.
|
||||
- Workflows `filter(Boolean)` for killed agents and `log()` every skipped or
|
||||
dropped item — no silent truncation.
|
||||
- `validate.js` reports partial results (a failed stage becomes a failure entry,
|
||||
not a thrown error).
|
||||
- `hil-validate.js`: board access arbitrated by `hil_test.py` self-locking; a
|
||||
locked board fails fast with the holder info (never forced); wedged boards
|
||||
reported, not retried blindly.
|
||||
|
||||
## Success criteria
|
||||
|
||||
Each piece smoke-tested on a minimal real scope before the branch is done:
|
||||
|
||||
1. `validate.js` on `stm32f407disco` + `raspberry_pi_pico` (real build, unit
|
||||
tests, size compare, PVS) returns a correct verdict object.
|
||||
2. `fanout-dev.js` on a trivial 2-port task on this branch; diffs build clean
|
||||
and are `.clang-format`-clean.
|
||||
3. `driver-review.js` on 2 driver dirs returns only verified findings.
|
||||
4. `hil-validate.js` on 1 board against the real rig with the actions-runner
|
||||
ACTIVE throughout: while a `board_lock.py` hold is in place the run fails
|
||||
fast citing the holder and returns the board in `locked[]`; with the lock
|
||||
still held, `force: true` proceeds (user-authorized bypass); after
|
||||
release a normal run passes; a direct `hil_test.py` run under a held lock
|
||||
fails that board without flashing.
|
||||
5. `/pre-pr` end-to-end on this branch's own diff.
|
||||
6. `pr-babysit.js` with `autoPush: false` (dry cycle) on a real open PR:
|
||||
CI failures classified correctly, at least one bot finding correctly
|
||||
validated or rejected, proposed fixes produced but not pushed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- CI (GitHub Actions) integration — this harness is for interactive sessions.
|
||||
- Fuzzing orchestration.
|
||||
- usbtest/usbtest-host stress batteries (existing separate branch).
|
||||
- Removing the untracked `port-audit.js` prototype from the master working
|
||||
tree happens when this branch merges (it is not tracked by git).
|
||||
@ -855,6 +855,10 @@ static bool hfclk_running(void) {
|
||||
|
||||
#if CFG_TUD_NRF_NRFX_VERSION == 1
|
||||
return nrf_clock_hf_is_running(NRF_CLOCK_HFCLK_HIGH_ACCURACY);
|
||||
#elif CFG_TUD_NRF_NRFX_VERSION == 2
|
||||
// nrfx 2.0.0 (MDK 8.29.0) has no nrf_clock_is_running(); it arrived in 2.1.0.
|
||||
// nrf_clock_hf_is_running() is present in all of 2.0.0-2.11.0 (deprecated from 2.1.0).
|
||||
return nrf_clock_hf_is_running(NRF_CLOCK, NRF_CLOCK_HFCLK_HIGH_ACCURACY);
|
||||
#else
|
||||
return nrf_clock_is_running(NRF_CLOCK, NRF_CLOCK_DOMAIN_HFCLK, NULL);
|
||||
#endif
|
||||
|
||||
254
test/hil/board_lock.py
Executable file
254
test/hil/board_lock.py
Executable file
@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Per-board advisory locks for the HIL rig.
|
||||
|
||||
Arbitrates board access between dev sessions and CI's hil_test.py without
|
||||
stopping the actions-runner. Locks are kernel flocks: the kernel releases
|
||||
them automatically when the holder process dies, and holders clear their
|
||||
lock-file record on release so records stay truthful (/tmp also clears on
|
||||
reboot).
|
||||
|
||||
Usage:
|
||||
board_lock.py hold BOARD [BOARD...] --reason TEXT
|
||||
board_lock.py hold --all [--config CONFIG.json] --reason TEXT
|
||||
board_lock.py release BOARD [BOARD...] | release --all
|
||||
board_lock.py status
|
||||
|
||||
A holder process holds ALL boards given in one `hold` call; releasing any of
|
||||
them kills that holder and releases all of its boards.
|
||||
"""
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
LOCK_DIR = '/tmp/tinyusb-hil-locks'
|
||||
|
||||
|
||||
def lock_path(board: str) -> str:
|
||||
return os.path.join(LOCK_DIR, f'{board}.lock')
|
||||
|
||||
|
||||
def boards_from_config(config: str) -> list:
|
||||
try:
|
||||
with open(config) as f:
|
||||
return [b['name'] for b in json.load(f)['boards']]
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
print(f'ERROR: cannot read board roster {config}: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def read_info(board: str):
|
||||
try:
|
||||
with open(lock_path(board)) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def is_locked(board: str) -> bool:
|
||||
"""True if the recorded holder process is still alive.
|
||||
|
||||
Deliberately never touches the flock: even a momentary probe lock would
|
||||
make a concurrent acquirer's LOCK_NB attempt fail spuriously. The flock
|
||||
taken by acquirers themselves stays the only authority."""
|
||||
info = read_info(board)
|
||||
pid = info.get('pid') if isinstance(info, dict) else None
|
||||
if not isinstance(pid, int) or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True # alive but owned by another user (e.g. the CI runner)
|
||||
return True
|
||||
|
||||
|
||||
def cmd_hold(boards, reason):
|
||||
os.makedirs(LOCK_DIR, exist_ok=True)
|
||||
# No pre-check: the holder's own LOCK_NB flock is the only authority — a
|
||||
# recorded pid may be stale or recycled (e.g. a live hil_test.py worker
|
||||
# that already released this board's flock but not its record).
|
||||
# The holder signals success through this pipe. A generic is_locked()
|
||||
# poll would be fooled by a RIVAL invocation's flock — only the holder
|
||||
# itself knows whether it won every board.
|
||||
r_fd, w_fd = os.pipe()
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
os.close(w_fd)
|
||||
os.waitpid(pid, 0) # reap intermediate child
|
||||
ready, _, _ = select.select([r_fd], [], [], 10)
|
||||
ok = bool(ready) and os.read(r_fd, 1) == b'1'
|
||||
os.close(r_fd)
|
||||
if ok:
|
||||
print(f'held: {", ".join(boards)}')
|
||||
return 0
|
||||
for b in boards:
|
||||
info = read_info(b)
|
||||
if info:
|
||||
print(f'ERROR: {b} locked: {info}', file=sys.stderr)
|
||||
print('ERROR: holder failed to acquire locks', file=sys.stderr)
|
||||
return 1
|
||||
# intermediate child: detach, then spawn the actual holder
|
||||
os.setsid()
|
||||
if os.fork() > 0:
|
||||
os._exit(0)
|
||||
# holder (grandchild): acquire all flocks, signal the parent, sleep until killed
|
||||
os.close(r_fd)
|
||||
# Keep the success pipe clear of fds 0-2: invoked with stdio closed,
|
||||
# os.pipe() can land there and the dup2 loop below would clobber it.
|
||||
if w_fd <= 2:
|
||||
w_fd = fcntl.fcntl(w_fd, fcntl.F_DUPFD, 3)
|
||||
# Detach stdio: a `hold` whose output is captured must see EOF when the
|
||||
# front-end exits — the immortal holder must not keep that pipe open.
|
||||
devnull = os.open(os.devnull, os.O_RDWR)
|
||||
for std_fd in (0, 1, 2):
|
||||
os.dup2(devnull, std_fd)
|
||||
if devnull > 2:
|
||||
os.close(devnull)
|
||||
try:
|
||||
handles = []
|
||||
for b in boards:
|
||||
# O_RDWR without O_TRUNC: never truncate before the flock is
|
||||
# held — a losing racer must not wipe the winner's holder info.
|
||||
fd = os.open(lock_path(b), os.O_RDWR | os.O_CREAT, 0o666)
|
||||
fh = os.fdopen(fd, 'r+')
|
||||
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fh.truncate(0)
|
||||
fh.seek(0)
|
||||
json.dump({'pid': os.getpid(), 'reason': reason,
|
||||
'since': time.strftime('%Y-%m-%dT%H:%M:%S%z')}, fh)
|
||||
fh.flush()
|
||||
handles.append(fh)
|
||||
except OSError:
|
||||
try:
|
||||
os.write(w_fd, b'0')
|
||||
except OSError:
|
||||
pass
|
||||
os._exit(1) # lost a race; parent reports the failure
|
||||
os.write(w_fd, b'1')
|
||||
os.close(w_fd)
|
||||
|
||||
def _bow_out(*_):
|
||||
# clear the records before dying so read_info/status stay truthful
|
||||
# (the kernel drops the flocks themselves on exit either way)
|
||||
for h in handles:
|
||||
try:
|
||||
h.truncate(0)
|
||||
except OSError:
|
||||
pass
|
||||
os._exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, _bow_out)
|
||||
while True:
|
||||
signal.pause()
|
||||
|
||||
|
||||
def cmd_release(boards):
|
||||
rc = 0
|
||||
victims = set()
|
||||
for b in boards:
|
||||
try:
|
||||
fd = os.open(lock_path(b), os.O_RDWR)
|
||||
except OSError:
|
||||
continue # no lock file (or another user's): nothing we can release
|
||||
fh = os.fdopen(fd, 'r+')
|
||||
try:
|
||||
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
# flock genuinely held — never SIGTERM on a mere pid record: the
|
||||
# pid may be recycled, or a live worker that already moved on.
|
||||
fh.close()
|
||||
info = read_info(b) or {}
|
||||
pid = info.get('pid')
|
||||
if info.get('reason') == 'hil_test.py':
|
||||
print(f'ERROR: {b} is mid-test by hil_test.py (pid {pid}) — not killing a '
|
||||
'CI run; wait for it to finish', file=sys.stderr)
|
||||
rc = 1
|
||||
elif isinstance(pid, int) and pid > 0:
|
||||
victims.add(pid)
|
||||
else:
|
||||
print(f'ERROR: {b} is held but its record is unreadable', file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
# flock was free: only a stale record remained — clear it
|
||||
try:
|
||||
fh.truncate(0)
|
||||
except OSError:
|
||||
pass
|
||||
fh.close()
|
||||
for holder in sorted(victims):
|
||||
try:
|
||||
os.kill(holder, signal.SIGTERM)
|
||||
print(f'released holder pid {holder}')
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
print(f'ERROR: holder pid {holder} belongs to another user — cannot signal it',
|
||||
file=sys.stderr)
|
||||
rc = 1
|
||||
time.sleep(0.3)
|
||||
still = [b for b in boards if is_locked(b)]
|
||||
if still:
|
||||
print(f'ERROR: still locked: {", ".join(still)}', file=sys.stderr)
|
||||
return 1
|
||||
return rc
|
||||
|
||||
|
||||
def cmd_status():
|
||||
if not os.path.isdir(LOCK_DIR):
|
||||
print('no locks')
|
||||
return 0
|
||||
any_locked = False
|
||||
for fn in sorted(os.listdir(LOCK_DIR)):
|
||||
if not fn.endswith('.lock'):
|
||||
continue
|
||||
b = fn[:-5]
|
||||
if is_locked(b):
|
||||
any_locked = True
|
||||
print(f'{b}: {read_info(b)}')
|
||||
if not any_locked:
|
||||
print('no locks')
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest='cmd', required=True)
|
||||
p_hold = sub.add_parser('hold')
|
||||
p_hold.add_argument('boards', nargs='*')
|
||||
p_hold.add_argument('--all', action='store_true')
|
||||
p_hold.add_argument('--config',
|
||||
default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
'tinyusb.json'),
|
||||
help='board roster JSON (default: tinyusb.json beside this script)')
|
||||
p_hold.add_argument('--reason', required=True)
|
||||
p_rel = sub.add_parser('release')
|
||||
p_rel.add_argument('boards', nargs='*')
|
||||
p_rel.add_argument('--all', action='store_true')
|
||||
sub.add_parser('status')
|
||||
a = ap.parse_args()
|
||||
if a.cmd == 'hold':
|
||||
boards = boards_from_config(a.config) if a.all else a.boards
|
||||
if not boards:
|
||||
ap.error('no boards given (name boards or use --all)')
|
||||
sys.exit(cmd_hold(boards, a.reason))
|
||||
if a.cmd == 'release':
|
||||
if a.all:
|
||||
boards = ([fn[:-5] for fn in os.listdir(LOCK_DIR) if fn.endswith('.lock')]
|
||||
if os.path.isdir(LOCK_DIR) else [])
|
||||
else:
|
||||
boards = a.boards
|
||||
if not boards:
|
||||
ap.error('no boards given (name boards or use --all)')
|
||||
sys.exit(cmd_release(boards))
|
||||
sys.exit(cmd_status())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@ -23,9 +23,8 @@
|
||||
"device": true, "host": false, "dual": false
|
||||
},
|
||||
"flasher": {
|
||||
"name": "jlink",
|
||||
"uid": "770935966",
|
||||
"args": "-device STM32F746NG"
|
||||
"name": "stlink",
|
||||
"uid": "0670FF515448787067122222"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -66,6 +66,51 @@ import ctypes
|
||||
from pymtp import MTP
|
||||
import string
|
||||
|
||||
# --- per-board dev-session locks (see test/hil/board_lock.py) ------------
|
||||
BOARD_LOCK_DIR = '/tmp/tinyusb-hil-locks'
|
||||
|
||||
def acquire_board_lock(board_name):
|
||||
"""Take this board's flock for the duration of its flash+test.
|
||||
Returns an open file handle (keep it referenced; closing releases it),
|
||||
or None when HIL_NO_BOARD_LOCK=1 or the lock dir is unusable (fail-open:
|
||||
locking must never break a test run by itself).
|
||||
Raises RuntimeError only when another session holds the board."""
|
||||
import fcntl
|
||||
if os.environ.get('HIL_NO_BOARD_LOCK') == '1':
|
||||
return None # user-authorized bypass — see board_lock.py / hil skill
|
||||
try:
|
||||
os.makedirs(BOARD_LOCK_DIR, exist_ok=True)
|
||||
fd = os.open(os.path.join(BOARD_LOCK_DIR, f'{board_name}.lock'),
|
||||
os.O_RDWR | os.O_CREAT, 0o666)
|
||||
fh = os.fdopen(fd, 'r+')
|
||||
except OSError as e:
|
||||
# odd lock dir (perms, path collision): proceed unlocked, but say so —
|
||||
# a silent fail-open is indistinguishable from the intentional bypass
|
||||
print(f'warning: board lock unavailable for {board_name} ({e}); proceeding unlocked',
|
||||
flush=True)
|
||||
return None
|
||||
try:
|
||||
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
try:
|
||||
info = fh.read(500).strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
info = ''
|
||||
fh.close()
|
||||
raise RuntimeError(f'board locked: {info or "unknown holder"}')
|
||||
# announce ourselves so the other side's conflict message is truthful;
|
||||
# best-effort — the flock itself is already held
|
||||
try:
|
||||
fh.truncate(0)
|
||||
fh.seek(0)
|
||||
json.dump({'pid': os.getpid(), 'reason': 'hil_test.py',
|
||||
'since': time.strftime('%Y-%m-%dT%H:%M:%S%z')}, fh)
|
||||
fh.flush()
|
||||
except OSError:
|
||||
pass
|
||||
return fh
|
||||
|
||||
|
||||
# Enumeration wait budget. The first attempt gets ENUM_TIMEOUT; retry attempts get the
|
||||
# shorter ENUM_TIMEOUT_RETRY - the board was just re-flashed again, and a device that is
|
||||
# going to enumerate shows up within a few seconds, so a failing test costs ~3-5x a
|
||||
@ -1885,77 +1930,96 @@ def test_board(board: Board) -> tuple[str, int, list[str], list]:
|
||||
name = board['name']
|
||||
flasher = board['flasher']
|
||||
|
||||
# default to all tests
|
||||
test_list = []
|
||||
try:
|
||||
_lock_fh = acquire_board_lock(name)
|
||||
except RuntimeError as e:
|
||||
log_line(f'{name:25} {STATUS_FAILED}: {e}')
|
||||
# visible report row so the ❌ matches the exit code; failed-tests stays
|
||||
# empty so a re-run repeats the whole board (no bogus -bt test filter)
|
||||
return name, 1, [], [(name, {'board-locked': 'fail'})]
|
||||
try:
|
||||
# default to all tests
|
||||
test_list = []
|
||||
|
||||
if name in board_test:
|
||||
test_list = board_test[name]
|
||||
elif len(test_only) > 0:
|
||||
# Explicit -t: filter against the board's capabilities so a device-only
|
||||
# board doesn't try to run host/dual tests (the test functions need a
|
||||
# `dev_attached` entry in the board config that won't exist).
|
||||
board_tests = board.get('tests', {})
|
||||
if 'only' in board_tests:
|
||||
allowed = set(board_tests['only'])
|
||||
test_list = [t for t in test_only if t in allowed]
|
||||
else:
|
||||
for t in test_only:
|
||||
category = t.split('/', 1)[0]
|
||||
if board_tests.get(category) is True:
|
||||
test_list.append(t)
|
||||
else:
|
||||
if 'tests' in board:
|
||||
board_tests = board['tests']
|
||||
if board_tests.get('device') is True:
|
||||
test_list += list(device_tests)
|
||||
if board_tests.get('dual') is True:
|
||||
test_list += dual_tests
|
||||
if board_tests.get('host') is True:
|
||||
test_list += host_test
|
||||
if name in board_test:
|
||||
test_list = board_test[name]
|
||||
elif len(test_only) > 0:
|
||||
# Explicit -t: filter against the board's capabilities so a device-only
|
||||
# board doesn't try to run host/dual tests (the test functions need a
|
||||
# `dev_attached` entry in the board config that won't exist).
|
||||
board_tests = board.get('tests', {})
|
||||
if 'only' in board_tests:
|
||||
test_list = board_tests['only']
|
||||
if 'skip' in board_tests:
|
||||
for skip in board_tests['skip']:
|
||||
if skip in test_list:
|
||||
test_list.remove(skip)
|
||||
log_line(f'{name:25} {skip:30} ... Skip')
|
||||
allowed = set(board_tests['only'])
|
||||
test_list = [t for t in test_only if t in allowed]
|
||||
else:
|
||||
for t in test_only:
|
||||
category = t.split('/', 1)[0]
|
||||
if board_tests.get(category) is True:
|
||||
test_list.append(t)
|
||||
else:
|
||||
if 'tests' in board:
|
||||
board_tests = board['tests']
|
||||
if board_tests.get('device') is True:
|
||||
test_list += list(device_tests)
|
||||
if board_tests.get('dual') is True:
|
||||
test_list += dual_tests
|
||||
if board_tests.get('host') is True:
|
||||
test_list += host_test
|
||||
if 'only' in board_tests:
|
||||
test_list = board_tests['only']
|
||||
if 'skip' in board_tests:
|
||||
for skip in board_tests['skip']:
|
||||
if skip in test_list:
|
||||
test_list.remove(skip)
|
||||
log_line(f'{name:25} {skip:30} ... Skip')
|
||||
|
||||
err_count = 0
|
||||
failed_tests = []
|
||||
rows = [] # list of (row_label, {example: status}) — one row per build variant
|
||||
variants = board.get('variant') or [{'name': name, 'flags': ''}]
|
||||
err_count = 0
|
||||
failed_tests = []
|
||||
rows = [] # list of (row_label, {example: status}) — one row per build variant
|
||||
variants = board.get('variant') or [{'name': name, 'flags': ''}]
|
||||
|
||||
prev_last = None # last test of the previous variant: the variant boundary is an adjacency too
|
||||
for v in variants:
|
||||
vname = v['name']
|
||||
# Shuffle each (board, variant)'s run order — de-synchronizes the worker pool so
|
||||
# usbtest batteries and flash churn spread across the timeline instead of convoying,
|
||||
# and surfaces order-dependent bugs. Seeded for replay (HIL_SHUFFLE_SEED, logged by
|
||||
# main). Unique per-example PIDs make any two different examples re-enumerate; only
|
||||
# the variant boundary can repeat the same example (same PID) — swap it away.
|
||||
run_list = list(test_list)
|
||||
if shuffle_seed is not None and len(run_list) > 1:
|
||||
random.Random(f'{shuffle_seed}:{name}:{vname}').shuffle(run_list)
|
||||
if run_list[0] == prev_last:
|
||||
run_list[0], run_list[-1] = run_list[-1], run_list[0]
|
||||
log_line(f'{vname:40} test order: {", ".join(t.rsplit("/", 1)[-1] for t in run_list)}')
|
||||
if run_list:
|
||||
prev_last = run_list[-1]
|
||||
cells = {}
|
||||
for test in run_list:
|
||||
ec, status, metric = test_example(board, vname, test)
|
||||
err_count += ec
|
||||
cells[test] = metric if metric else status
|
||||
if ec > 0:
|
||||
failed_tests.append(test)
|
||||
rows.append((vname, cells))
|
||||
prev_last = None # last test of the previous variant: the variant boundary is an adjacency too
|
||||
for v in variants:
|
||||
vname = v['name']
|
||||
# Shuffle each (board, variant)'s run order — de-synchronizes the worker pool so
|
||||
# usbtest batteries and flash churn spread across the timeline instead of convoying,
|
||||
# and surfaces order-dependent bugs. Seeded for replay (HIL_SHUFFLE_SEED, logged by
|
||||
# main). Unique per-example PIDs make any two different examples re-enumerate; only
|
||||
# the variant boundary can repeat the same example (same PID) — swap it away.
|
||||
run_list = list(test_list)
|
||||
if shuffle_seed is not None and len(run_list) > 1:
|
||||
random.Random(f'{shuffle_seed}:{name}:{vname}').shuffle(run_list)
|
||||
if run_list[0] == prev_last:
|
||||
run_list[0], run_list[-1] = run_list[-1], run_list[0]
|
||||
log_line(f'{vname:40} test order: {", ".join(t.rsplit("/", 1)[-1] for t in run_list)}')
|
||||
if run_list:
|
||||
prev_last = run_list[-1]
|
||||
cells = {}
|
||||
for test in run_list:
|
||||
ec, status, metric = test_example(board, vname, test)
|
||||
err_count += ec
|
||||
cells[test] = metric if metric else status
|
||||
if ec > 0:
|
||||
failed_tests.append(test)
|
||||
rows.append((vname, cells))
|
||||
|
||||
# flash board_test last to disable board's usb (skipped when --skip-flash is set);
|
||||
# this is teardown/park, not a test — not recorded in the report
|
||||
if not skip_flash:
|
||||
test_example(board, variants[0]['name'], 'device/board_test')
|
||||
# flash board_test last to disable board's usb (skipped when --skip-flash is set);
|
||||
# this is teardown/park, not a test — not recorded in the report
|
||||
if not skip_flash:
|
||||
test_example(board, variants[0]['name'], 'device/board_test')
|
||||
|
||||
return name, err_count, sorted(set(failed_tests)), rows
|
||||
return name, err_count, sorted(set(failed_tests)), rows
|
||||
finally:
|
||||
if _lock_fh:
|
||||
try:
|
||||
# clear our pid record before dropping the flock: this worker
|
||||
# process lives on (pool reuse), so a stale record would make
|
||||
# board_lock.py's pid-liveness checks report a freed board as
|
||||
# still locked for the rest of the run
|
||||
_lock_fh.truncate(0)
|
||||
except OSError:
|
||||
pass
|
||||
_lock_fh.close()
|
||||
|
||||
|
||||
REPORT_MD = 'hil_report.md'
|
||||
@ -2035,7 +2099,17 @@ def accumulate_report(mret: list, report_dir: Path, fresh: bool) -> str:
|
||||
pass # corrupt/old sidecar: start fresh
|
||||
|
||||
# merge this run: current cells override prior for boards/tests that ran
|
||||
for _, _, _, rows in mret:
|
||||
for name, _, _, rows in mret:
|
||||
if rows and not any('board-locked' in cells for _, cells in rows):
|
||||
# board ran for real this time: clear a stale lock-failure cell
|
||||
# (its row is keyed by board name; test rows may be variant names)
|
||||
stale = acc.get(name)
|
||||
if stale is not None:
|
||||
stale.pop('board-locked', None)
|
||||
if not stale:
|
||||
# variant-keyed boards never repopulate the board-name row —
|
||||
# drop it or it renders as a blank ghost row
|
||||
del acc[name]
|
||||
for row_label, cells in rows:
|
||||
acc.setdefault(row_label, {}).update(cells)
|
||||
|
||||
@ -2098,6 +2172,11 @@ def main() -> None:
|
||||
if len(boards) == 0:
|
||||
config_boards = [e for e in config['boards'] if e['name'] not in skip_boards]
|
||||
else:
|
||||
unknown = [b for b in boards if b not in {e['name'] for e in config['boards']}]
|
||||
if unknown:
|
||||
# exiting 0 with 'No tests were run.' would read as a green HIL run
|
||||
print(f'ERROR: board(s) not in {config_file.name}: {", ".join(unknown)}')
|
||||
sys.exit(1)
|
||||
config_boards = [e for e in config['boards'] if e['name'] in boards]
|
||||
|
||||
build_err = 0
|
||||
|
||||
Reference in New Issue
Block a user