diff --git a/docs/two-agent-field-report-2026-08-12.md b/docs/two-agent-field-report-2026-08-12.md index 090310a0..4522ea42 100644 --- a/docs/two-agent-field-report-2026-08-12.md +++ b/docs/two-agent-field-report-2026-08-12.md @@ -97,43 +97,97 @@ diffing. Nothing in the protocol said who owned a piece of writing. --- -## The background watcher +## The background watcher — launch it exactly this way -Agent B watched the branch with a detached shell loop, not a model-driven poll: +This is the part that was hardest to convey to the second agent, who ended up launching it differently and +got something that looked identical and did not work. The mechanism matters more than the script. + +### The requirement, stated so it survives a different harness + +> A **shell process, detached, owned by the agent's harness, that exits when it has something to say** — and +> whose exit **re-invokes the agent**. + +Three properties, and dropping any one breaks it in a way that is not obvious from watching it run: + +1. **The waiting happens in the shell, not in the model.** No inference per tick. +2. **The harness owns the process**, so its exit is an event the harness delivers to the agent. +3. **It exits on detection.** A watcher that notices a change and keeps running has told nobody. + +### The launch + +In Claude Code this is the Bash tool with `run_in_background: true`. Whatever the harness, it must be *that +harness's* background mechanism — the one that notifies on completion — and not a shell backgrounding +operator. ```bash +cd /path/to/repo || exit 1 BASE=$(git rev-parse HEAD) +echo "watching origin/ from base=$BASE" for i in $(seq 1 2880); do - NEW=$(git ls-remote origin | awk '{print $1}') - [ "$NEW" != "$BASE" ] && { echo "PUSH_DETECTED $NEW"; exit 0; } + NEW=$(timeout 30 git ls-remote origin 2>/dev/null | awk '{print $1}') + if [ -n "$NEW" ] && [ "$NEW" != "$BASE" ]; then + echo "PUSH_DETECTED"; echo "base=$BASE"; echo "new=$NEW"; exit 0 + fi sleep 30 done +echo "WATCHER_TIMEOUT no push in ~24h base=$BASE" +exit 1 ``` -**Why a shell loop and not the agent.** Polling in the loop costs nothing: no model inference happens per -tick, so an idle watcher is free. A model-driven wake re-reads the whole conversation every time it checks — -at 30-second granularity that is ruinous, and beyond about five minutes it also misses the prompt cache and -pays a full uncached read. Pushing the waiting *below* the model turns an expensive poll into a free one. +Every line of that is load-bearing: -**Cost lands in two places only:** when the accumulated output enters the model's context, and the single -re-invocation when the process exits. So the loop is deliberately silent per tick — one `echo` per iteration -would be 2,880 lines to swallow later. +| choice | why | what you get instead | +|---|---|---| +| `git ls-remote` | reads the remote, mutates nothing | `git fetch` moves refs under a working tree that may be mid-edit | +| `timeout 30` on the call | a hung network call would freeze the loop silently | a watcher that is alive and blind | +| one `echo` at start, then silence | the output enters the agent's context on wake | one line per tick = 2,880 lines to swallow | +| `exit 0` on detection | the exit **is** the notification | it notices and nobody hears | +| `seq 1 2880` | runaway backstop | a process nobody remembers, polling forever | +| `sleep 30` | free, because no model runs | see below | -**`ls-remote`, not `fetch`.** Reads the remote without mutating the local repo, so the watcher cannot disturb -a working tree mid-edit. +### Why 30 seconds is free here and ruinous in the model -**What broke:** +An idle watcher costs **nothing**. Measured: 85 bytes of output over seven minutes, no model inference at +all. The agent is suspended between turns; the loop is just a process. -- **Self-tripping.** Every push by the watching agent woke its own watcher. The cause was starting a new - watcher without stopping the old one, so two ran at once. Cheap but noisy, and it cost real attention. -- **The wake is not free even though the wait is.** Each firing re-reads the entire conversation. Over a long - session that grows monotonically, so late wakes cost far more than early ones — an argument for short-lived - sessions per event rather than one immortal session. -- **A missed window is silent.** Twice the watcher was down (session restart) and pushes landed unnoticed; - they were only found by a manual `git log`. A watcher that dies looks exactly like a branch with no - activity. +Cost appears in exactly two places — when the accumulated output enters the context, and the single +re-invocation when the process exits. Both happen **once**, on the event. ---- +A model-driven poll is a different thing wearing the same clothes. There the model wakes each tick and +re-reads the entire conversation to decide "nothing yet". At 30-second granularity that is enormous, and +there is a second trap: the prompt cache has roughly a five-minute TTL, so any model-side wake spaced beyond +that reads the whole context uncached and pays full price. Pushing the waiting *below* the model turns an +unaffordable poll into a free one. + +### The four ways to launch it that look right and are not + +**1. `nohup … &` or any shell backgrounding.** The process runs, polls correctly, detects the push, and exits — +and **the agent is never told**, because the harness is not tracking it. I did this myself and only noticed +because I re-read my own command. It fails silently and looks perfect: a running process, a correct script, +and an agent that sits there forever. + +**2. A model-driven interval** — `/loop 30s`, a scheduler, a wake-up timer. Functionally correct, and it pays +a full context read per tick to learn nothing. This is the one to warn a new agent about first, because it is +the intuitive design and the expense is invisible. + +**3. A loop that does not exit on detection** — printing "found it" and continuing. There is no mechanism by +which that reaches the agent. The output file grows and no one reads it. + +**4. Chatty output.** Any per-tick logging is deferred cost: silent while it accumulates, then all of it +lands in the context at once on wake. + +### Two operational failures worth pre-empting + +**Self-tripping.** An agent that pushes while its own watcher is live wakes itself. The real cause is +starting a new watcher without stopping the old one, so two run concurrently and the stale one fires on your +own commit. **Stop the previous watcher before starting the next**, and re-base the new one on the head you +just pushed. + +**Silent death.** If the session restarts, the watcher dies, and a dead watcher is indistinguishable from a +quiet branch. Twice, pushes landed unnoticed and were found by a manual `git log`. Anything long-running +needs a liveness signal of its own, or the eventual replacement of polling with a webhook — the repo is a +Gitea instance the platform already runs, and an event delivered is one that cannot be missed by a process +that stopped existing. ## Identity: the gap that made the record unreliable