--- id: "009" title: Report the Gap Instead of Reminding About It status: complete amends: specs/001-agent-mail-cli.md, specs/006-inbox-watch.md blocked_by: [] blocks: [] --- # Report the Gap Instead of Reminding About It ## Overview Agents fail to re-arm their watch. When they do, they go silently unreachable: the mailbox keeps accepting messages for them, the sender sees no error, and the lapse surfaces only as unexplained quiet. Two things make it likely. The rule lives in `describe`'s invariants — read once at orientation, often many turns and a context compaction before the moment it matters — and it is phrased as advice (*"re-arm it after every wake"*), which competes with whatever the agent was about to do next and loses. This is the same failure shape as spec 008's timeout: the flag's own description asked for a large number and agents passed 30 anyway. **Instructions did not hold there either.** The tool cannot make re-arming automatic. Only a process the *harness* is tracking can wake an agent when it exits, so a watch that spawned its own successor would produce an orphan that finds mail and has nobody to tell. Re-arming is structurally the agent's own call to make. What the tool can do is stop *asking* and start *reporting*: state at exit that the agent is now unreachable, hand it the exact commands to fix that, and keep the lapse visible afterwards rather than mentioning it once. Every place this project replaced an instruction with a reported fact — the resolved mailbox path, the expected-versus-found binary, the mailbox size — behavior improved. The re-arm rule is the last holdout still written as advice. Ships as `0.3.0`. Purely additive: no field is removed, no existing value changes meaning. > **Completion rule:** This spec is not complete until all acceptance criteria are verified through the testing approach below, including a real lapse observed end to end on the published build — a watch woken, deliberately not re-armed, and the resulting state read back from a second process. Build-only verification is insufficient. ## Goals - A watch that exits says, as a fact about the present, that the agent is no longer reachable — and says what to run. - "A watch is armed right now" and "this agent was watching recently" become separate answers, because they are separate questions and one signal cannot serve both. - A lapse stays visible after the moment it happens, instead of being announced once. - An agent that never uses `watch` is never nagged about it. ## Requirements ### Functional Requirements - **FR1**: On exit, `watch` reports whether the watched identity is still reachable and the exact commands that would restore reachability, with the identity already substituted. - **FR2**: The reported next commands reflect what actually happened: a wake that delivered mail names both `read` and `watch`; an empty return names only `watch`. - **FR3**: `watch` records why it exited — delivered or timed out — and when. - **FR4**: `status` distinguishes a watch that is armed right now, one that exited and was never replaced, one whose process died without exiting cleanly, and an identity that has never watched. - **FR5**: `status` retains `watching` with its existing recency-based meaning, unchanged. - **FR6**: `send` tells a sender that has stopped watching that it is unreachable, and what to run. A sender that has never watched is not told anything. - **FR7**: A mailbox created by an earlier version opens with no manual migration, keeping its messages, and gains the session table on first use. ### Non-Functional Requirements - **NFR1**: Every addition is a new key or a new column. No existing key is removed, renamed, or changed in meaning, so an existing caller keeps working untouched. - **NFR2**: The JSON output and error contract is unchanged (spec 001 NFR1, NFR2), and `read` and `status` keep returning arrays. - **NFR3**: No new runtime dependencies, and no changes to the CI, release, npm publish, or WinGet workflows. ### Technical Constraints - **TC1**: Liveness moves to a new `watch_sessions(id, agent, started_at, last_seen, state, released_at, released_reason)` table, **one row per watch process** rather than one per identity. `state` is `armed` or `released`; `released_reason` is `delivered` or `timeout`. The old `watchers` table is left in place, unread, so nothing is deleted from an existing mailbox. - **TC2**: Each `watch` opens its own session row as `armed`, heartbeats that row, and marks that row `released` with a reason and timestamp immediately before emitting its payload. It never touches another watcher's row. - **TC3**: `status.watch_state` is derived from every session for that identity, and `armed` wins: `armed` when any session is `armed` with a heartbeat inside the liveness window; `stale` when the newest `armed` session's heartbeat is older than the newest clean exit; `released` when the newest outcome is a clean exit; `never` when there are no sessions. - **TC4**: `status.watching` remains true whenever the last heartbeat is within the liveness window, regardless of `watch_state`. - **TC5**: `watch`'s payload gains `reachable` and `next`. `reachable` is computed after the release is recorded, so it is true only if some *other* watcher for that identity is armed. - **TC6**: `send`'s payload gains `reachable` and `next` only when the sender's `watch_state` is `released` or `stale`. The keys are absent for `armed` and `never`. - **TC7**: Ships as `0.3.0` through the existing PyPI, GitHub Release, and npm channels. ### Requirement Traceability | Requirement | Acceptance Criteria | |---|---| | FR1 | AC1, AC2 | | FR2 | AC3 | | FR3 | AC4 | | FR4 | AC5, AC6, AC7 | | FR5 | AC8 | | FR6 | AC9, AC10 | | FR7 | AC11 | | NFR1 | AC12 | | NFR2 | AC12 | | NFR3 | AC13 | | TC1 | AC11 | | TC2 | AC4 | | TC3 | AC5, AC6, AC7 | | TC4 | AC8 | | TC5 | AC1, AC2 | | TC6 | AC9, AC10 | | TC7 | AC14, AC15 | ## Reported Shape A wake that delivered mail: ```json { "agent": "research:worker", "messages": [ { "id": "…", "sender": "research:lead", "subject": "…", "type": "direct", "created": "…" } ], "count": 1, "timeout": false, "reachable": false, "next": [ "agent-mail read research:worker", "agent-mail watch research:worker" ] } ``` An empty return — nothing to read, so only one next step: ```json { "agent": "research:worker", "messages": [], "count": 0, "timeout": true, "reachable": false, "next": ["agent-mail watch research:worker"] } ``` `status`, with the two questions separated: ```json [ { "agent": "research:worker", "unread": 1, "unacked": 1, "watching": false, "last_seen": "2026-08-03T12:04:11-04:00", "watch_state": "released", "released_at": "2026-08-03T12:04:11-04:00" } ] ``` ## Key Decisions ### State, not advice *"Remember to re-arm the watch"* is guidance, and guidance competes with the task the agent was woken to do. *"You are not reachable"* is a claim about the present that the agent has to reconcile before it can consider itself done. The difference costs nothing to implement and is the difference between a reminder and a discrepancy. It is also the pattern this project keeps rediscovering. `storage.path` replaced a documented default; `expected`/`found` replaced "binary not found"; `size_bytes` replaced a retention policy. Each time, reporting the fact beat instructing the reader. The re-arm rule was the last one still phrased as an instruction, and it is the one that kept failing. ### The commands are literal, not described `next` carries runnable strings with the identity already substituted. Composing `agent-mail watch ` is a step where an agent can use the wrong identity, add a removed flag, or simply not bother. Handing it the string removes the step. This is the smallest possible version of the same idea behind `describe`: give the agent the thing, not a description of the thing. ### `reachable` is computed, not hardcoded It would almost always be `false`, and hardcoding it would be simpler. But a second watcher on the same identity is possible, and a field that says `false` while a watch is genuinely armed is exactly the plausible-wrong-answer failure this project has paid for three times. Computing it costs one query. ### Two fields, because there are two questions `watching` answers *"has this agent been alive recently?"* — the sender's question, deliberately tolerant of the seconds between a wake and a re-arm so a healthy agent never flickers. `watch_state` answers *"is a watch armed right now?"* — the enforcement question, which needs no tolerance at all. One signal cannot serve both, and trying to make it do so is what produced the gap: `watching: true` currently covers both a blocked watcher and one that exited thirty seconds ago and is never coming back. Keeping `watching` unchanged and adding a sharper neighbour means no existing reader is disturbed, and anything that needs precision has somewhere to get it. ### One row per watch, not one per identity Keying liveness by identity looked obviously right and was wrong. Two watchers on the same identity share a single row, so the second one's exit marks the identity `released` while the first is still blocked — `reachable` would report `false` with a watch genuinely armed. That is the plausible-wrong-answer failure, reintroduced by the field added to prevent it, and it was caught only because AC2 asked for the two-watcher case explicitly. A watcher is a *session*, not an identity. With a row per process, each watch releases only its own and `armed` wins over every stale or released sibling — so the answer is right no matter how many watchers overlap or in what order they exit. The old `watchers` table is left in place and unread rather than dropped: liveness is transient, but silently deleting anything is the habit this project keeps paying to unlearn. ### `stale` is worth distinguishing from `released` A watch that exits cleanly records that it did. A process killed by a laptop shutdown, a crash, or an antivirus quarantine never gets the chance — its row stays `armed` and simply stops refreshing. Both leave the agent unreachable, but they mean different things: `released` says the agent chose to stop and forgot to resume, `stale` says something killed it. The fixes differ, and the incident on 2026-07-31 was exactly the second kind. ### The nag rides on `send`, and only for agents that watch The exit statement fires once. If it is ignored, nothing mentions the lapse again — so it needs a second surface, and `send` is the right one: an implementer that was woken, did the work, and reported back is at that exact moment one step from ending its turn deaf. That is where the reminder is worth the most. It is scoped to identities whose `watch_state` is `released` or `stale`, because a strategist that drives a loop and never watches would otherwise be told it was unreachable on every message it sent — noise that would teach agents to ignore the field. **`read` cannot carry it.** `read` returns a JSON array, so there is no top-level object to attach a key to, and changing that shape would break every existing caller for a warning. This is a real gap — `read` is the most likely next call after a wake — and it is accepted rather than solved here. ### This raises the odds; it does not close them Nothing in this spec makes re-arming automatic, because nothing in the tool can. A harness-level gate — a Claude Code `Stop` hook that refuses to end a turn while `watch_state` is not `armed` — is the only mechanism that makes the failure unreachable, and it lives outside this repository. What ships here is what works in every harness, and what gives that hook a precise signal to check. ## Implementation Tasks - [x] Add the per-process `watch_sessions` table, leaving the old `watchers` table untouched. - [x] Arm a session when a watch starts and release it, with a reason, immediately before its payload is emitted. - [x] Add `reachable` and `next` to `watch`'s payload, with the next steps reflecting whether mail was delivered. - [x] Report `watch_state` and `released_at` from `status`, leaving `watching` unchanged. - [x] Add `reachable` and `next` to `send`'s payload for senders that have stopped watching, and omit them otherwise. - [x] Rewrite the re-arm invariant in the schema as a statement of consequence rather than an instruction. - [x] Add regression coverage for each state, for the migration, and for the omission rule on `send`. - [x] Amend specs 001 and 006. - [x] Bump the version to `0.3.0` and add the CHANGELOG entry. - [x] Release `v0.3.0` after JJ's approval and verify the published artifacts. ## Verification Record - [x] `ruff check src/ tests/` passed; `pytest` passed 94 tests, 0 skipped, with the PyInstaller binary and npm wrapper both available. - [x] **AC2 caught a real design defect before release.** Liveness was keyed by identity, so two concurrent watchers shared one row and the second one's exit marked the identity released while the first was still blocked — `reachable` would have reported false with a watch genuinely armed. Rebuilt as one row per watch process (`watch_sessions`), where `armed` wins over any released or stale sibling. The criterion that found it was the two-watcher case, which existed only because the spec refused to hardcode `reachable`. - [x] Two test defects of my own were fixed rather than worked around: one sent mail to the identity it then watched (so the watch returned instantly instead of staying armed), and one planted an aged session beside a fresh one and expected the agent to read as gone — which is wrong, since `watching` asks whether *any* watcher was seen recently. - [x] `python -m build` produced the `0.3.0` wheel and sdist; `twine check` passed on both. - [x] CI passed on `main` for Python 3.10-3.13 plus lint and build. - [x] Release workflow `30848635271` succeeded; GitHub Release `v0.3.0` carries the wheel, the sdist, and the four platform binaries. PyPI and npm both serve `0.3.0` (PyPI's JSON API served a cached `0.2.0` for a few minutes first). - [x] `npm-smoke.yml` passed on Windows x64, Linux x64, macOS x64, and macOS arm64. - [x] AC15 verified against the globally installed `0.3.0` on the live shared mailbox, as a full cycle: `status` reported `watch_state: armed` while blocked; a real send woke it and the payload carried `reachable: false` with `next` naming read then watch; `status` then reported `watch_state: released` **while `watching` was still true**, which is the divergence the split exists for; and the next `send` from that identity carried `reachable: false` and the arm command. ## Acceptance Criteria - [x] **AC1** (`integration`): A `watch` that returns mail reports `reachable: false` once no other watcher is armed for that identity. - [x] **AC2** (`integration`): With a second watcher armed for the same identity, the exiting `watch` reports `reachable: true`. - [x] **AC3** (`integration`): A wake carrying mail reports `next` as the `read` command followed by the `watch` command, both naming the watched identity; an empty return reports only the `watch` command. - [x] **AC4** (`integration`): After a wake, the liveness row reads `released` with reason `delivered`; after an empty return, `released` with reason `timeout`; both carry `released_at`. - [x] **AC5** (`integration`): `status.watch_state` is `armed` while a watch is blocked in another process. - [x] **AC6** (`integration`): `status.watch_state` is `released` after a watch exits, and `never` for an identity that has only ever sent or received. - [x] **AC7** (`integration`): A liveness row left `armed` with a heartbeat older than the window reports `watch_state` `stale`, distinguishing a killed process from a clean exit. - [x] **AC8** (`integration`): `status.watching` still reports true for the whole liveness window after a watch exits, so a healthy agent between wake and re-arm does not read as gone. - [x] **AC9** (`integration`): A `send` from an identity whose `watch_state` is `released` or `stale` carries `reachable: false` and a `next` naming the watch command for that sender. - [x] **AC10** (`integration`): A `send` from an identity that has never watched, or whose watch is armed, carries neither key. - [x] **AC11** (`integration`): A mailbox that predates this version opens with no manual step, keeps every stored message, and gains the `watch_sessions` table. Its pre-existing liveness rows are not carried over — liveness is transient by nature and is only meaningful for a minute. - [x] **AC12** (`integration`): Every field present in `0.2.0` output is still present with the same meaning; `read` and `status` still return arrays. - [x] **AC13** (`integration`): `ruff check src/ tests/` and the full `pytest` suite pass on the existing CI matrix with no workflow changes and no new runtime dependencies. - [x] **AC14** (`manual`): GitHub Release `v0.3.0` carries the six artifacts required by spec 002 AC3, PyPI and npm both serve `0.3.0`, and the smoke workflow passes on all four platforms. - [x] **AC15** (`manual`): On JJ's machine against the live mailbox, the installed `0.3.0` shows a full lapse end to end: a watch woken by a real send reports `reachable: false` with its `next` commands, `status` then reports `watch_state: released`, and a subsequent `send` from that identity carries the unreachable keys. ## Testing Approach ### Validation steps 1. Drive the armed state with a real second process, as spec 006 does — the state is about a process being blocked, which an in-process fake cannot show. 2. Produce `stale` by writing an old heartbeat directly rather than waiting out the window. 3. Build a pre-`0.3.0` `watchers` table by hand to test the migration, since the claim is about somebody else's existing file. ### Test cases | Situation | Expected | |---|---| | `watch` returns mail | `reachable: false`, `next` = read then watch, row `released`/`delivered` | | `watch --once` on an empty inbox | `reachable: false`, `next` = watch only, row `released`/`timeout` | | A second watcher armed elsewhere | `reachable: true` | | `status` while a watch is blocked | `watch_state: armed`, `watching: true` | | `status` right after a watch exits | `watch_state: released`, `watching: true` | | `status` with an old `armed` heartbeat | `watch_state: stale`, `watching: false` | | `status` for a send-only identity | `watch_state: never`, `released_at: null` | | `send` from a released identity | `reachable: false` plus `next` | | `send` from a never-watched identity | Neither key present | | A `0.2.0` mailbox opened by this version | Opens unchanged; messages preserved; session table created | ### Release validation ```bash gh run watch gh release view v0.3.0 npm view @juanjofuchs/agent-mail@0.3.0 version ``` ## Out of Scope - Making re-arming automatic. Only a harness-tracked process can wake an agent, so the tool cannot own this. - The Claude Code `Stop` hook gate. It is the mechanism that makes the failure unreachable, but it is configuration on JJ's machine, not code in this repository. - Changing `read` or `status` from arrays to objects so they could carry a warning. - Warning on `ack`, `cleanup`, or `describe`. - Any configurable threshold for when a lapse is reported, or a separate liveness window for `watch_state`. - Notifying the *sender* that a recipient is unreachable at send time — `status` already answers that on demand. - Persisting a history of watch sessions. One current state per identity is what the questions need. ## References - spec 001: [`specs/001-agent-mail-cli.md`](001-agent-mail-cli.md) — the behavioral contract this spec amends. - spec 006: [`specs/006-inbox-watch.md`](006-inbox-watch.md) — introduced `watch` and the liveness row this spec splits. - spec 007: [`specs/007-wrapper-resolution.md`](007-wrapper-resolution.md) — the report-the-evidence precedent, applied there to a missing binary. - spec 008: [`specs/008-remove-the-wrong-choices.md`](008-remove-the-wrong-choices.md) — established that instructions do not hold and the fix must be structural. - Implementation: [`src/agent_mail/cli.py`](../src/agent_mail/cli.py).