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

13 KiB
Raw Blame History

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.