a73x

c8c2ea7e

Record the loop proven on real VMs, and the bug it found

a73x   2026-09-06 09:20

Commit message
Record the loop proven on real VMs, and the bug it found

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.