From 7f13dbf3eb5fc67c7b7cbf217b96f32e42d7ac7f Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Wed, 9 Sep 2026 07:40:48 +0700 Subject: [PATCH] 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). --- 11-Features.md | 44 ++++++++++++++++++++++++++++++++++++++++ 15-REST-API-Reference.md | 8 +++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/11-Features.md b/11-Features.md index 47acf9c..7a9ebd0 100644 --- a/11-Features.md +++ b/11-Features.md @@ -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. diff --git a/15-REST-API-Reference.md b/15-REST-API-Reference.md index 27c848f..4ed2c84 100644 --- a/15-REST-API-Reference.md +++ b/15-REST-API-Reference.md @@ -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