document how to launch the watcher, since the mechanism is what did not transfer

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 16:46:42 +00:00
co-authored by Claude Opus 5
parent e9d0261e87
commit 015e280e5c
+77 -23
View File
@@ -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/<branch> from base=$BASE"
for i in $(seq 1 2880); do
NEW=$(git ls-remote origin <branch> | awk '{print $1}')
[ "$NEW" != "$BASE" ] && { echo "PUSH_DETECTED $NEW"; exit 0; }
NEW=$(timeout 30 git ls-remote origin <branch> 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