From 015e280e5c3f45372a9e187ddbe739bf416ff433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 12 Aug 2026 16:44:20 +0000 Subject: [PATCH] document how to launch the watcher, since the mechanism is what did not transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner could not convey this to the second agent, who launched it differently and got something that looked identical and did not work. The script was never the hard part; the mechanism is. States the requirement so it survives a different harness — a detached shell process owned by the agent's harness, which exits when it has something to say, and whose exit re-invokes the agent — and notes that dropping any one of those three breaks it invisibly. Then the four wrong ways, each of which looks correct while running. Backgrounding with nohup or & produces a process that polls correctly, detects the push, exits, and never tells the agent, because the harness is not tracking it; I made that exact mistake and caught it only by re-reading my own command. A model-driven interval is functionally correct and pays a full context re-read per tick to learn nothing — the intuitive design, and the expensive one, which is why it is the first thing to warn a new agent about. A loop that does not exit on detection has no path to the agent at all. And per-tick logging is deferred cost that lands all at once on wake. Also records why 30s polling is free in a shell and ruinous in the model, including the five-minute prompt-cache TTL that makes any model-side wake beyond it pay for a full uncached read. Co-Authored-By: Claude Opus 5 --- docs/two-agent-field-report-2026-08-12.md | 100 +++++++++++++++++----- 1 file changed, 77 insertions(+), 23 deletions(-) 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