Files
platform/docs/agent-waits.md
T
pastilhasandClaude Opus 5 8aaea6bfcc the cost that matters is the useless wake, not the useful one
Most waits find nothing almost always: a daily release check says no 360 days a year, and a branch
watcher wakes on every push including everyone else. So the number to optimise is the useless wake
times how many there will be.

The fix is not a cheaper wake, it is pushing the relevance test into the wait condition so that firing
implies relevance. Wait on a push THAT CONTAINS a COMMS file, not on a push. Wait on a version string
that differs, not on a page that changed. Both are shell tests with no model in them.

Three tiers, most events dying at the first: shell condition (free), fresh minimal agent (one small
cold read), escalate with real context (a full read of a long session). A context-inheriting fork that
returns nothing to the parent is tier two done well, but it is still a read, so it is the fallback for
when relevance needs judgement rather than the default.

For the platform this means a condition belongs in the declaration, not in the agent that wakes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 00:45:59 +00:00

251 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Waits: how an agent waits for something without burning context
**Status:** draft, 2026-08-13. One mechanism proven (git remote polling, run twice); everything else here is
specification and reasoning. Claims are marked **measured** or **reasoned** — do not let that slip.
`docs/two-agent-field-report-2026-08-12.md` describes this for one purpose: one agent waiting on another's
push. That was where it was discovered, not where it belongs. This file is about the primitive itself,
because the same shape answers "wait for CI", "wait for the job to finish", "wait for the container to go
healthy", "wait for a reply", and a dozen other things Officer already needs.
---
## The primitive
> A **wait** is a harness-owned process that blocks until a condition holds, then exits — and whose exit
> re-invokes the agent.
Three properties. Drop any one and it breaks in a way that is not visible from watching it run:
1. **The waiting happens below the model.** No inference per tick. The agent is suspended.
2. **The harness owns the process**, so its exit is an event the harness delivers. A process the harness is
not tracking can finish perfectly and tell nobody.
3. **It exits when it has something to say.** The exit *is* the notification. A wait that detects and keeps
running has informed no one.
Everything below follows from those three.
---
## The cost model, which decides everything else
This is the part that is easy to get half-right, and half-right is what leads people to build the expensive
version.
| | cost |
|---|---|
| a tick while waiting | **nothing** — no model runs |
| a thousand ticks | **nothing** |
| **each wake** | a full context read, uncached |
**Measured** (field report, 2026-08-12): an idle watcher produced 85 bytes over seven minutes with zero
inference. **Measured** tonight: two fires, each costing exactly one wake.
**Reasoned, and the part usually missed:** a wake re-reads the entire conversation, and conversations only
grow. So the cost of a wait is not `duration` — it is `fires × context-at-the-time`. Idle is free forever;
the tenth notification in a long session costs several times the first.
Worse, waits are the exact workload the prompt cache cannot help. The TTL is about five minutes; anything
worth waiting for takes longer than that. **Every wake is an uncached read, by construction.**
Two consequences that should drive design:
- **Say less on wake.** The output that survives to the wake enters the context permanently. One line per
tick over 24h is 2,880 lines that land at once and then stay.
- **Prefer many short sessions to one long one.** A wait in a fresh session costs a constant amount per
event. The same wait in an immortal session costs monotonically more. This is the single strongest
argument for event-driven agents over resident ones.
---
## Prefer blocking over polling. Prefer events over both.
The git watcher polls because a git remote can only be *asked*. Most things Officer waits on are not like
that, and a poll is the worst of the three options that usually exist.
**Tier 1 — block on the kernel.** Zero syscalls while waiting, and detection is immediate rather than
average-half-an-interval late.
| waiting for | how to block |
|---|---|
| a file appearing or changing | `inotifywait -q -e close_write,create,moved_to <path>` |
| a process to exit | `tail --pid=<pid> -f /dev/null` |
| a lock to release | `flock <file> true` |
| a line on a pipe or log | `read -r line < <fifo>` |
| an inbound HTTP callback | a listener that blocks on `accept()` |
| whichever of several finishes first | `wait -n` over background pids |
**Tier 2 — block on the service.** Some services will hold a connection open and tell you.
| waiting for | how |
|---|---|
| a row to change | Postgres `LISTEN` / `NOTIFY` — the connection blocks, the database pushes |
| new mail | IMAP `IDLE` |
| a container to change state | `docker events --filter …` (streams, blocks) |
| a systemd unit | `systemctl --wait` / journal follow |
Officer keeps almost everything in one Postgres. `LISTEN`/`NOTIFY` is therefore the highest-leverage
unbuilt piece here: job completion, a new chat message, a status flip, all become blocking waits with no
polling anywhere.
**Tier 3 — poll, because the source can only be asked.** A git remote, a third-party HTTP API, a health
endpoint. Then the rules are: read-only calls (`git ls-remote`, never `git fetch` — a fetch mutates refs
under a working tree that may be mid-edit), a `timeout` on every call so a hung network call cannot leave
the wait alive and blind, and an interval matched to how fast the thing actually changes.
**Never poll in the model.** A scheduled wake-up, a `/loop 30s`, a "check every minute" — these are the same
shape wearing the same clothes and they pay a full uncached context read *per tick* to learn nothing. This
is the intuitive design and its expense is invisible, which is why it needs saying first.
---
## Make firing mean something
The rest of this file is about how to wait cheaply. This section is about the other half, and it is the one
that decides whether a fleet of these is affordable.
**Most waits find nothing, almost always.** A daily release check answers "no" 360 days a year. A branch
watcher wakes on every push, including everyone else's. So the number that matters is not the cost of a
useful wake — it is the cost of a useless one, multiplied by how many there will be.
The fix is not a cheaper wake. It is to **push the relevance test into the wait condition**, so that firing
already implies relevance:
- **Do not** wait on "a push", then wake and check whether it carries a `COMMS/<branch>/NN-*.md`. Wait on a
push *that contains one* — a filename test the shell can do with no model at all.
- **Do not** wait on "the releases page changed", then wake and read it. Wait on "the version string differs
from my cursor" — a string compare.
Three tiers, and almost everything should die at the first:
| tier | cost | for |
|---|---|---|
| **shell condition** | zero | anything expressible as a filename, a diff, a version, a status |
| **fresh minimal agent** | one small cold read | relevance genuinely needs judgement, but not history |
| **escalate with real context** | a full read of a long session | the event has to be interpreted against what came before |
A session fork that inherits context but returns nothing to it (Claude Code's `/btw`) is tier two done well.
It is still a context read, so it is the fallback when a shell test cannot express relevance — not the
default.
**Corollary for the platform:** a wait's condition should be part of its declaration, not something the agent
evaluates after waking. `wait for: push to <branch> touching COMMS/**` is a cheaper and more honest thing to
build than `wait for: push` plus an agent that decides.
## The contract a wait must honour
Specification. None of this is built yet.
**Exit codes are the vocabulary.**
```
0 fired — the condition holds; payload on stdout
1 timed out — the bounded lifetime elapsed, nothing happened
2 broke — the wait itself failed and is no longer trustworthy
```
`1` and `2` must be distinguishable. "Nothing happened" and "I stopped being able to tell" are opposite
facts and a wait that conflates them is worse than no wait, because absence reads as reassurance.
**Output is a payload, not a log.** One line on arm so there is a record of what was watched; silence while
waiting; a minimal structured payload on fire. Everything printed is permanent context.
**A cursor, persisted.** The wait is armed at a position — a SHA, a byte offset, a row id, a timestamp — and
that position belongs on disk, not only in the process. Then a re-arm after a restart neither misses events
nor re-reports old ones. The git watcher currently holds its base only in memory, which is why a session
restart loses the thread.
**Bounded lifetime, and the bound is not "forever".** `seq 1 2880` is a runaway backstop, not a policy. A
wait that times out should re-arm from its cursor rather than die silently.
**Liveness must be externally checkable.** A dead wait and a quiet one are indistinguishable, and that
ambiguity has already cost two missed pushes. Cheapest fix: touch a heartbeat file each tick, so `mtime`
answers "is it alive" without asking the process. In a UI that shows running processes — as Officer's chat
does — the chip itself is the signal, which is a real advantage and should be kept.
**Idempotent re-arm, and self-trip protection.** An agent that acts and then wakes on its own action is a
loop. Re-arm from the position *after* your own change, and never run two waits on the same condition.
---
## Where this applies in Officer
The reason to generalise. Each of these is a place something currently either blocks a turn, gets polled by
a human, or is discovered late.
| wait | tier | notes |
|---|---|---|
| a pipeline/script job finishes | 1 or 2 | `data/jobs/<id>.log` is a file — inotify. Or `NOTIFY` on the row |
| a download completes | 1 | same, and the progress sentinel already exists |
| a container becomes healthy | 2 | `docker events` |
| a member logs into `claude` for the first time | 1 | `~/.claude/.credentials.json` appearing — currently polled by `/agent-status` |
| new mail arrives | 2 | IMAP IDLE, in the email sidecar |
| CI, a deploy, a remote build | 3 | poll, with a timeout |
| a push to any repo | 3 today, **event tomorrow** | Gitea is ours: a webhook removes the wait entirely |
| a long `db:push` or migration finishes | 1 | process wait |
| disk crosses a threshold | 3 | slow-moving; poll infrequently |
| **a human replies** | 1 | an approval gate: the agent arms a wait and stops costing anything until answered |
That last row is the one worth dwelling on. An agent that needs a decision currently either blocks a session
or asks and forgets. A wait makes "stopped, pending your answer" cost nothing while it lasts.
---
## Choosing a lifetime
| shape | when | cost |
|---|---|---|
| **wait inside a live session** | the agent holds context the event needs interpreting against | free while idle, growing per fire |
| **wait, then hand off** | context matters up to the fire, not after | one growing session, then reset |
| **no wait — event spawns a fresh agent** | the event carries everything needed (a SHA, a job id) | constant per event, forever |
The third is the destination for anything recurring. The first is right for tonight's watcher, where the
value is that I already know what the commits mean.
The rule: **if the payload plus the repo is enough to act on, do not keep a session alive to receive it.**
---
## Failure modes
| pattern | what it looks like |
|---|---|
| **launched outside the harness** | `nohup … &` — runs, detects, exits, and no one is told. Looks perfect |
| **model-driven poll** | correct behaviour, full context read per tick |
| **detects but does not exit** | prints "found it" into a file nobody reads |
| **chatty** | per-tick output, deferred, all landing at once on wake |
| **silent death** | session restarts, wait dies, quiet branch and dead watcher look identical |
| **self-trip** | agent's own push wakes it, usually because an old wait was never stopped |
| **timeout mistaken for quiet** | exit 1 treated as "nothing happened" when it means "I stopped looking" |
| **mutating poll** | `git fetch` in a loop, moving refs under a working tree |
---
## Open questions
1. **Is a wait a platform feature or an agent habit?** Officer has a job runner, a Gitea instance and a
sidecar pattern. `POST /waits {condition, payload}` returning when it fires is a plausible platform
primitive — and would make waits available to capabilities, not only to agents.
2. **What arms a wait for an agent that is not running?** The webhook shape needs the platform to spawn the
agent, which is `send-claude-code` plus a trigger. Most of that exists.
3. **Should waits be declarative?** `wait for: file:<path>` / `pg:notify:<channel>` / `git:<remote>/<branch>`
— a small vocabulary compiled to the right tier, so nobody hand-writes a poll for something inotify could
have blocked on.
4. **How does a wait survive a session restart** without either missing its event or re-firing on an old
one? The cursor answers half of it; the other half is who re-arms.
5. **What is the right granularity of notification?** One wake per push, or one wake per batch after a quiet
period? Batching trades latency for context, and context is the scarce thing.
---
## Provenance
The mechanism, the three properties and the four wrong ways to launch it come from
`docs/two-agent-field-report-2026-08-12.md`, which recorded them after they were learned the hard way. What
this file adds is the cost model stated as a formula rather than an anecdote, the block-over-poll hierarchy,
the exit-code contract, and the argument that the destination is event-spawned short-lived agents rather
than resident ones.
Nothing in "the contract" or "where this applies" has been implemented. The only thing running today is a
tier-3 git poll, which is the good version of the wrong shape.