--- id: "011" title: Close the Loop status: pending amends: specs/001-agent-mail-cli.md blocked_by: [] blocks: [] --- # Close the Loop ## Overview Agents keep dropping out of the coordination loop in two specific places, and both were "fixed" once already by writing the rule into `describe`. **They stop watching.** A watch that returns has stopped, and unless the agent arms another one it is deaf — mail is still accepted for it, the sender sees no error, and the only symptom is silence. Spec 009 added `reachable` and `next` to `watch` for exactly this, and it was not enough: the field appears on `watch`, but the moment an agent actually drops the thread is *after* `read`, when it has the content, does the work, and ends its turn. Spec 009 recorded that gap and accepted it, because `read` returned a bare JSON array with nowhere to attach a key. **They do not acknowledge.** Measured on the live mailbox, one sender→recipient edge carried six directions and six unacknowledged. Acking was a second command, composed per message, after the real work was already done — and the fourth composition is where the habit died. That matters because `unacked` is the signal a supervisor reads: the gap between "I have it" and "I did something about it". The pattern that fixed the identical failure in the sibling voice-tunnel CLI is not more documentation but **state-keyed guidance**: every command answers with what to do next, computed from the state that call just observed. `describe` is read once at orientation and is a manual by the time it matters; an agent holding fifty other instructions will not re-derive a rule it read an hour ago. This spec also lands `view`, the human dashboard that made both failures visible in the first place. Ships as `0.5.0`. This is a breaking change: `read` and `ack` change output shape. > **Completion rule:** This spec is not complete until all acceptance criteria are verified through the testing approach below, including a real agent loop driven end to end against the published `0.5.0` — a wake, a read, an ack, and a re-arm — with the `next` list shrinking as each step is taken. Build-only verification is insufficient. ## Goals - An agent is told what to do next at the moment it applies, not at orientation. - Acknowledging costs one call for a whole batch, and a reply settles it for free. - Removing the friction does not destroy the signal: `unacked` still means "acted on", not "looked at". - A human can see which agents are stuck without reading any messages. ## Requirements ### Functional Requirements - **FR1**: `watch`, `read`, and `ack` each report whether the identity is reachable and a `next` list of literal commands that close the remaining gap. - **FR2**: `next` contains only steps that are actually outstanding for the state just observed: take delivery when mail was detected, acknowledge when something is unacknowledged, re-arm when no watch is armed. - **FR3**: Each entry in `next` is a runnable command with the identity already substituted. - **FR4**: `ack` accepts any number of message ids in one call, and `--all` acknowledges everything outstanding for that identity. - **FR5**: `send --reply-to ` acknowledges the message it answers, names the acknowledged id in its receipt, and can be declined with `--no-ack`. - **FR6**: `read --ack` acknowledges everything the read returns, and is opt-in. - **FR7**: `view` renders a live, read-only dashboard of the mailbox: per-agent watch state, unread and unacknowledged counts, how long the oldest unread has waited, and directed message flow. - **FR8**: `view` never mutates the mailbox and never marks a message read. - **FR9**: `describe` documents the `next` contract and the acknowledgement paths, and continues to omit `view`. ### Non-Functional Requirements - **NFR1**: The JSON output and error contract is otherwise unchanged (spec 001 NFR1, NFR2). - **NFR2**: No new runtime dependencies; `view` is standard library only. - **NFR3**: No changes to the CI, release, npm publish, or WinGet workflows. ### Technical Constraints - **TC1**: `read` returns an object — `{agent, messages, count, reachable, next}` — instead of a bare array. This is what gives the hint somewhere to live, and it is the breaking part of the change. `--fields` continues to project each entry of `messages`. - **TC2**: `ack` returns `{agent, acked_at, count, message_ids, reachable, next}`, replacing the single-message `{message_id, agent, acked_at}`. - **TC3**: `ack` with neither ids nor `--all` is an error naming both forms; combining them is an error; an unknown id is an error and nothing is acknowledged. - **TC4**: `ack --all` on an empty set is a count of zero, not an error. - **TC5**: `next` ordering is fixed: `read`, then `ack`, then `watch` — what blocks what. - **TC6**: `view` opens the mailbox read-only, refreshes on an interval, and reconstructs its history from stored timestamps so the first frame is already populated. - **TC7**: `view` ranks agents by a decayed activity score (frecency) over messages and watch heartbeats, and always shows an agent whose watch is armed. - **TC8**: `view` is absent from `describe`'s `commands`. - **TC9**: Ships as `0.5.0` through the existing PyPI, GitHub Release, and npm channels. ### Requirement Traceability | Requirement | Acceptance Criteria | |---|---| | FR1 | AC1, AC2, AC3 | | FR2 | AC2, AC4 | | FR3 | AC3 | | FR4 | AC5, AC6 | | FR5 | AC7 | | FR6 | AC8 | | FR7 | AC9, AC10, AC11 | | FR8 | AC12 | | FR9 | AC13 | | NFR1 | AC14 | | NFR2 | AC14 | | NFR3 | AC14 | | TC1 | AC1 | | TC2 | AC5 | | TC3 | AC6 | | TC4 | AC6 | | TC5 | AC2 | | TC6 | AC12 | | TC7 | AC11 | | TC8 | AC13 | | TC9 | AC15, AC16 | ## Reported Shape `read`, with mail delivered and nothing armed: ```json { "agent": "research:worker-1", "messages": [ { "id": "…", "subject": "Ship option B", "body": "…" } ], "count": 1, "reachable": false, "next": [ "agent-mail ack research:worker-1 --all", "agent-mail watch research:worker-1" ] } ``` `ack`, once nothing is outstanding but the watch is still down: ```json { "agent": "research:worker-1", "acked_at": "2026-08-05T13:41:39-04:00", "count": 3, "message_ids": ["…", "…", "…"], "reachable": false, "next": ["agent-mail watch research:worker-1"] } ``` ## Key Decisions ### Guidance keyed to state, not to memory `describe` is the interface contract and it is read once. By the time the rule matters — a woken agent finishing a task, forty turns later — it is a manual the agent will not re-read. A `next` list arrives at the moment it applies and carries only the branch that is actually true. This is lifted directly from the voice-tunnel CLI, where the same failure (an agent that stops listening) disappeared once every command answered with what to do next. The corollary is that `next` must shrink honestly. A list that always names three steps becomes a banner, and a banner is ignored; a list that names the ack step only when something is genuinely unacknowledged is information. ### `read` becomes an object, and that is the point Spec 009 declined this and said so plainly: "`read` cannot carry it. `read` returns a JSON array, so there is no top-level object to attach a key to... This is a real gap — `read` is the most likely next call after a wake — and it is accepted rather than solved here." It is the gap that matters most, because `read` is the last command before the agent goes off to do the work and end its turn. Keeping the array shape preserved a caller contract at the cost of the failure the whole feature exists to prevent. Breaking it is worth one major-ish version. ### Replying acknowledges, because replying *is* acting A reply is the strongest available evidence that a message was acted on, and it happens on a turn the agent was already taking. Folding the ack into it removes the cost entirely rather than merely reducing it. `--no-ack` exists for the reply that asks a question rather than answering one, and the receipt names what was acknowledged so the side effect is never silent. ### `read --ack` is opt-in, and stays that way Acking everything you read makes `unacked` a synonym for `unread`, which would raise the acknowledgement rate to 100% while destroying the measurement that motivated the work. The flag exists for the case where reading genuinely *is* acting, and its own description says when not to use it. The friction was worth removing; the meaning was not. ### The dashboard is read-only, and hidden from `describe` `view` opens the mailbox in read-only mode and never calls `read`. A human viewer that marked messages read would consume mail addressed to an agent — the same reasoning that makes `watch` report envelopes instead of delivering them. And it is deliberately absent from `describe`, because `describe` is what an agent reads and acts on, and a blocking TUI in that list is a hung turn waiting to happen. ### Frecency, and armed agents are never hidden Ranking by recency alone let a single recent message outrank an agent that had been trading twenty, so the ranking is a decayed event count with a thirty-minute half-life. Separately, slots go to armed agents first: ranked purely by score, a live watcher with little traffic was pushed out of a small window by day-old agents that merely had unread mail — and the header then reported five armed while the table listed four. A monitor that contradicts itself on screen is worse than no monitor. ## Implementation Tasks - [x] Add a state-keyed next-step helper and use it from `watch`, `read`, and `ack`. - [x] Change `read` to return an object carrying the messages plus `reachable` and `next`. - [x] Accept a batch of ids and `--all` on `ack`, and report `count` and `message_ids`. - [x] Acknowledge the replied-to message on `send --reply-to`, with `--no-ack` to decline. - [x] Add `read --ack`, opt-in, with the cost stated at the flag. - [x] Add the `view` dashboard as a read-only command absent from `describe`. - [x] Record the `next` contract and the acknowledgement paths in the schema invariants. - [x] Add regression coverage for every acceptance criterion below. - [ ] Amend spec 001 with the new output shapes and the `next` contract. - [ ] Bump the package version to `0.5.0` and add the CHANGELOG entry, calling out both breaking changes. - [ ] Release `v0.5.0` after JJ's approval and verify the published artifacts. ## Acceptance Criteria - [x] **AC1** (`integration`): `read` returns an object with `agent`, `messages`, `count`, `reachable`, and `next`; `--fields` still projects each entry of `messages`. - [x] **AC2** (`integration`): `next` lists only outstanding steps, in the order read → ack → watch: a wake names all three, a read names ack and watch, and once nothing is unacknowledged only watch remains. - [x] **AC3** (`integration`): every entry in `next` is a runnable command carrying the agent's own identity. - [x] **AC4** (`integration`): with a watch armed elsewhere for the same identity, `reachable` is true and no watch step is emitted. - [x] **AC5** (`integration`): `ack ` acknowledges the batch in one call and reports `count` and `message_ids`. - [x] **AC6** (`integration`): `ack --all` clears everything outstanding and is a zero count when nothing is; ack with neither ids nor `--all`, with both, or with an unknown id is a JSON error that acknowledges nothing. - [x] **AC7** (`integration`): `send --reply-to` acknowledges the answered message and names it in the receipt; `--no-ack` leaves it unacknowledged. - [x] **AC8** (`integration`): plain `read` leaves messages unacknowledged; `read --ack` acknowledges what it returned, including with `--no-mark-read`. - [x] **AC9** (`integration`): `view --once` renders the fleet with watch state, counts, and the oldest-unread wait, and exits 0. - [x] **AC10** (`integration`): the flow panel shows directed edges and the count each recipient has not acknowledged. - [x] **AC11** (`integration`): an agent whose watch is armed appears even when busier agents would fill the window. - [x] **AC12** (`integration`): rendering the dashboard marks nothing read, changes no counts, and creates no mailbox when none exists. - [x] **AC13** (`integration`): `describe` documents the `next` contract and the acknowledgement paths, and `view` is absent from its `commands`. - [x] **AC14** (`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. - [ ] **AC15** (`manual`): GitHub Release `v0.5.0` carries the six artifacts required by spec 002 AC3, PyPI and npm both serve `0.5.0`, and the smoke workflow passes on all four platforms. - [ ] **AC16** (`manual`): against the live mailbox with the installed `0.5.0`, a full loop — watch, read, ack, re-arm — shows `next` shrinking at each step and `reachable` turning true once a watch is armed. ## Testing Approach ### Validation steps 1. Drive the loop with real commands in sequence and assert on `next` at each step, since the claim is about how the list changes rather than about any single payload. 2. Use a second process for the armed-elsewhere case; a watch that is genuinely blocked cannot be simulated in-process. 3. For `view`, assert against rendered frames at fixed terminal sizes, and force a window too small to list every agent so the ranking is actually exercised. 4. Re-run the suite with `AGENT_MAIL_RUNNER=binary` and `AGENT_MAIL_RUNNER=npm`. ### Test cases | Situation | Expected | |---|---| | `watch` wakes with mail | `next` = read, ack, watch | | `read` after that wake | `next` = ack, watch | | `ack --all`, then `read` | `next` = watch only | | A watch armed in another process | `reachable` true, no watch step | | `ack` with three ids | one call, `count` 3 | | `ack` with no ids and no `--all` | JSON error naming both forms | | `send --reply-to` | replied-to message acknowledged, id in receipt | | `read` then `status` | still unacknowledged — read is not ack | | `view --once` with an armed but quiet agent | that agent is listed | | `view --once` over an unread message | message still unread afterwards | ### Release validation ```bash gh run watch gh release view v0.5.0 npm view @juanjofuchs/agent-mail@0.5.0 version ``` A same-day `npx` check needs `npx --min-release-age=0`, per the note in spec 004. ## Out of Scope - Making re-arming automatic. Only a harness-tracked process can wake an agent, so the tool cannot own this (spec 009). - A `next` field on `status`, `cleanup`, or `describe` — none of them leaves an agent mid-loop. - Changing `status` from an array to an object. - Enforcing acknowledgement, or rejecting a send to an agent that is not listening. - Any interactivity in `view` beyond quitting and switching panels: no acking, reading, or sending from the dashboard. - Notifying a sender when a recipient goes deaf. - Configurable frecency half-life, liveness windows, or refresh floors. - Workflow changes for CI, release, npm publish, or WinGet. ## 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 watcher liveness. - spec 008: [`specs/008-remove-the-wrong-choices.md`](008-remove-the-wrong-choices.md) — established that documentation does not hold and the fix must be structural. - spec 009: [`specs/009-rearm-state.md`](009-rearm-state.md) — introduced `reachable`/`next` on `watch`, and recorded the `read` gap this spec closes. - Prior art in the same author's stack: the voice-tunnel CLI's per-command `next` action, which removed the identical stop-listening failure. - Implementation: [`src/agent_mail/cli.py`](../src/agent_mail/cli.py), [`src/agent_mail/view.py`](../src/agent_mail/view.py).