c8c2ea7e
Record the loop proven on real VMs, and the bug it found
a73x 2026-09-06 09:20
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -230,3 +230,83 @@ subagents were unavailable. | |||
| 230 | reading real user state. Its two halves are covered | 230 | reading real user state. Its two halves are covered |
| 231 | (`load_trust_policy_merges_global_and_repo_keys`, | 231 | (`load_trust_policy_merges_global_and_repo_keys`, |
| 232 | `global_trusted_keys_path_returns_config_path`); the composition is not. | 232 | `global_trusted_keys_path_returns_config_path`); the composition is not. |
| 233 | |||
| 234 | ## Proving the agent loop — 2026-09-06 | ||
| 235 | |||
| 236 | Ran the claim→clone→work→push→release loop for real: first as two processes on | ||
| 237 | one host, then as **two eitri VMs** (created, enrolled, raced, destroyed) against | ||
| 238 | a live `git-collab-server`. It worked — and it found a bug in under a minute | ||
| 239 | that 57 tests had gone green over. | ||
| 240 | |||
| 241 | Evidence: `loop-w1` claimed with token 1, cloned over SSH with a key it had | ||
| 242 | generated inside itself, pushed `refs/heads/loop-w1/version-flag` (`0e7b461`), | ||
| 243 | and released; `loop-w2` exited 4 and was told who held the lease and until when. | ||
| 244 | The worker contract is in `~/code/rad/workshop/durin/worker.sh`. | ||
| 245 | |||
| 246 | ### What worked | ||
| 247 | |||
| 248 | - **Real usage found what tests could not, immediately.** The whole point of | ||
| 249 | the slice. Within one run the demo exposed a bug in the ordinary path. | ||
| 250 | - **The worker needs only `ssh` and `git`.** Both VMs ran the loop with no | ||
| 251 | `git-collab` binary, no token and no account — the "contributing requires | ||
| 252 | nothing but plain git" invariant, demonstrated rather than asserted. | ||
| 253 | - Each VM generated its own keypair, so no private key ever left the machine | ||
| 254 | that used it, and enrolment was one append to `authorized_keys` — durin's | ||
| 255 | key-pool step, executed by hand and found to be enough. | ||
| 256 | - Reading the failure instead of the retro note. The note said this test failed | ||
| 257 | on `matches reject pattern`; it did not. | ||
| 258 | |||
| 259 | ### Lessons | ||
| 260 | |||
| 261 | - **Claiming by an abbreviated id worked and releasing by it silently did | ||
| 262 | not.** Lease rows are keyed by the full issue id; `acquire` resolved a prefix | ||
| 263 | against the repository's issue refs, but `renew`/`release` used the argument | ||
| 264 | literally, so `release <prefix>` updated a key no row used — and idempotency | ||
| 265 | reported that as `"status":"released"`, exit 0. The lease stayed held until | ||
| 266 | its TTL, blocking every other worker. This is the *ordinary* path: the CLI | ||
| 267 | abbreviates ids everywhere, so `issue claims` prints exactly the id that | ||
| 268 | `issue unclaim` then failed to release. Fixed in `fef0420` by resolving | ||
| 269 | against the lease table — the only thing that can answer "which lease did you | ||
| 270 | mean" once an issue is closed or its ref is gone. | ||
| 271 | - **A test asserted the buggy behaviour.** `release_without_a_lease_succeeds` | ||
| 272 | pinned `"status":"released"` for a release that freed nothing, so the suite | ||
| 273 | did not merely miss the bug, it protected it. Every lease test used a full id | ||
| 274 | on both sides of the claim/release pair, so the abbreviated path had no | ||
| 275 | coverage at all despite `acquire_by_issue_id_prefix_reports_full_id` existing. | ||
| 276 | A status that cannot distinguish "freed a lease" from "there was nothing to | ||
| 277 | free" is a status worth splitting: `release` now answers `not-held`. | ||
| 278 | - `pkill -f 'git-collab-server --config /tmp/loop'` matches **the shell's own | ||
| 279 | command line**, so it killed the shell running it — twice, while I blamed the | ||
| 280 | server for dying mysteriously. Use `pkill -f '[g]it-collab-server …'`. | ||
| 281 | - `set -e` plus `wait` on a job that is *supposed* to exit non-zero aborts the | ||
| 282 | script before it can report the result. The loser exiting 4 is the point. | ||
| 283 | - A background process does not survive the terminal invocation that started | ||
| 284 | it. Run a demonstration in one invocation. | ||
| 285 | |||
| 286 | ### What to change | ||
| 287 | |||
| 288 | - **A test that takes an id must exercise the abbreviated form on at least one | ||
| 289 | side of the pair.** That is what the CLI prints, so it is what humans and | ||
| 290 | scripts pass back in. Full-id-only tests are testing a path users do not take. | ||
| 291 | |||
| 292 | ### Actions for the next slice | ||
| 293 | |||
| 294 | - [ ] Add the `make base-check REF=<ref>` target (carried forward twice now). | ||
| 295 | - [ ] Decide whether to spend the control-socket safety margin (carried | ||
| 296 | forward: `SOCKET_PATH_LIMIT` 100 -> 104). | ||
| 297 | - [ ] Audit the remaining lease and issue commands for full-id-only test | ||
| 298 | coverage, now that one such gap has cost a real bug. | ||
| 299 | |||
| 300 | ### Retained debt | ||
| 301 | |||
| 302 | - **Whoever builds durin, next worker change:** the worker pushes a *branch*, | ||
| 303 | not a patch, because server-side patch creation from `refs/for/<base>` is | ||
| 304 | forge phase 3. A worker can take work and deliver code; the review round trip | ||
| 305 | is not closed through the forge yet, so the loop is proven only up to "code | ||
| 306 | arrives". | ||
| 307 | - **Whoever builds durin, first unattended run:** the loop was driven by hand. | ||
| 308 | Nothing yet watches the queue, hires a VM, or reaps one — and the VMs in this | ||
| 309 | run were created and destroyed by explicit API calls, not by a foreman. | ||
| 310 | - **Forge owner, next merge-scan change:** the worker writes a `Fixes: <id>` | ||
| 311 | trailer, which nothing consumes. `merge_scan` reads `Patch:` only, so the | ||
| 312 | issue was not closed by the push that fixed it. | ||