fleetd #365: a reply reports whether anything was waiting for it

The REST reply endpoint's response shape changed: 'delivered' is no longer
always true, and an 'outcome' field names which of three things happened.
15-REST-API-Reference.md still documented the old constant.

Adds the Features entry the charter requires for a visible behaviour change,
covering both doors and the nudge-counter rename (delivered -> sent).
Dai Ha
2026-09-09 07:40:48 +07:00
parent 8c4f1525ac
commit 7f13dbf3eb
2 changed files with 51 additions and 1 deletions
+44
@@ -83,6 +83,7 @@ six weeks, and the table alone will not carry it.
| [The model opencode actually ran is read back and checked](#the-model-opencode-actually-ran-is-read-back-and-checked) | automatic (opencode profiles) | #175 | `member/OpenCodeSessionDiscovery`, `member/OpenCodeLauncher` |
| [Every file a member must read follows the member's own user](#every-file-a-member-must-read-follows-the-members-own-user) | `memberHerdrSocket:` + `worktreeGroup:` | #222 #224 | `member/ClaudeCodeLauncher`, `session/GitWorktrees` |
| [A role fleetd cannot bind is refused](#a-role-fleetd-cannot-bind-is-refused-not-quietly-downgraded) | automatic | #123 | `auth/MemberRegistry` |
| [A reply says whether anything was waiting for it](#a-reply-says-whether-anything-was-waiting-for-it) | automatic | #365 | `msg/MessageService.ReplyOutcome` |
Nearly every knob above lives in one file, on one profile:
@@ -4712,3 +4713,46 @@ its `size()`. It does not count members a second way, so it always agrees with t
proof is a live `caffeinate` process, or a member that survives an idle night.
fleetd #355 / #374.
## A reply says whether anything was waiting for it
**What.** `fleet_reply` and `POST /sessions/{id}/reply` now report which of three things happened to
a worker's reply, instead of saying "delivered" for all of them:
| Outcome | What happened | REST `delivered` |
|---|---|---|
| `resolved_send` | A `fleet_send` or `fleet_ask` was actively waiting, and took the reply now | `true` |
| `resolved_async_ticket` | No live waiter, but the reply completed a parked async ticket — a `fleet_poll` caller sees it at once | `true` |
| `queued` | Nothing was waiting. The reply is held in the inbox for a later drain | `false` |
Over MCP the tool result carries the wording, for example `queued — no send or ticket was waiting;
held in the inbox for a later drain`. Over REST the body gains an `outcome` field, and `delivered`
stops being a constant.
**The knob.** None. It is how both doors answer now.
**Why it exists.** All three outcomes are successes, but they are not the same fact, and the caller
could not tell them apart. "Delivered" for a reply nobody was waiting for is the report reading
better than the state — the same failure this project keeps finding in other places. A lead that
sees `queued` knows its send never opened, or had already timed out, which is a real and different
situation from a clean handoff. Before this, that difference was visible only in a metrics label
nobody reads during a task.
The nudge counters were renamed in the same change, for the same reason. `fleet_push_nudges_total`
and `fleet_lead_heartbeat_nudges_total` used to record an outcome called `delivered`; it is now
`sent`. Nothing about the count changed — only the word. That call is a one-way herdr
paste-and-submit into a pane, and there is no read-receipt concept at that layer, so "sent" is the
most the counter can ever honestly claim.
**Gotchas.**
- **`delivered: false` over REST is not an error.** The status is still `200`, and the reply is
safely held. Any client that treats `delivered: false` as a failure and retries will queue the
reply twice. This is the one behaviour change that can break an existing caller, and it is the
reason the `outcome` field exists: branch on `outcome`, not on `delivered`.
- **`queued` does not mean the lead will never see it.** It means nothing was waiting *at that
moment*. The reply is drainable with `fleet_poll{target}`, and the push loop nudges the lead.
- **The wording is not a contract; `outcome` is.** The human-readable text may be reworded. The
three `outcome` values (`resolved_send`, `resolved_async_ticket`, `queued`) are the stable names.
fleetd #365.
+7 -1
@@ -91,7 +91,13 @@ A herdr-level failure during the blocking send (not the `turnId` path) maps thro
Handler: `replyMessage`, `FleetApp.java:554-571`. This is `fleet_reply`'s REST face.
Body: `{"content": "..."}`. Unlike `sendMessage`, there is no blank check on `content` — a missing
or empty value is delivered as `""`. Response: `200 {sessionId, delivered: true}`.
or empty value is delivered as `""`. Response: `200 {sessionId, delivered, outcome}`.
`delivered` used to be unconditionally `true`. Since fleetd #365 it says whether anything was
actually waiting: `true` when the reply resolved an open send or a parked async ticket, `false`
when it was only queued in the inbox for a later drain. Both are still `200` — a queued reply is
a success, not an error. `outcome` names which of the three happened: `resolved_send`,
`resolved_async_ticket` or `queued`.
The path's session id is the caller's own identity claim, and this is the one place that claim is
checked over REST — over MCP a worker's identity already comes from its connection, never an