Skip to main content

agentmux_srv/muxbus/cloud_subscriber/
delivery.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Claim → verify → deliver for one agent's pulled injections: the half of a
5//! pull that comes after the relay answered, shared by the single pull
6//! (`sync_agent_reactive`) and the batch pull (`sync_agents_batched`).
7
8use super::*;
9
10/// One agent's answer to a pending pull, single or batched.
11#[derive(Deserialize)]
12pub(super) struct PendingResp {
13    pub(super) injections: Vec<PendingInj>,
14    // What the relay dropped undelivered since the last pull. Absent
15    // from an older relay: nothing to report.
16    #[serde(default)]
17    pub(super) expired: Vec<delivery_status::notes::ExpiredItem>,
18}
19#[derive(Deserialize)]
20pub(super) struct PendingInj {
21    id: String,
22    source_agent: Option<String>,
23    message: String,
24    priority: Option<String>,
25    // When the relay received it (RFC 3339), for marking a late delivery.
26    #[serde(default)]
27    created_at: Option<String>,
28    // Present only for messages signed by an AgentMux-operated WAN
29    // sender (currently just the GitHub review-notification consumer,
30    // "reagent") — see agentmux_common::jekt_sign::verify_reagent_jekt.
31    // All four travel together; verification is attempted only when
32    // every one of them deserializes present (see below).
33    #[serde(default)]
34    reagent_sig: Option<String>,
35    #[serde(default)]
36    reagent_key_id: Option<String>,
37    #[serde(default)]
38    reagent_msg_id: Option<String>,
39    #[serde(default)]
40    reagent_ts_secs: Option<i64>,
41    // W3-S carried tuple + the cloud's same-account answer
42    // (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.1). Absent from an
43    // older cloud and on unsigned rows; verified by `wan_verify` below.
44    #[serde(default)]
45    wan_sig: Option<String>,
46    #[serde(default)]
47    wan_msg_id: Option<String>,
48    #[serde(default)]
49    wan_ts_secs: Option<i64>,
50    #[serde(default)]
51    wan_source_agent: Option<String>,
52    #[serde(default)]
53    wan_target_agent: Option<String>,
54    #[serde(default)]
55    wan_source_host: Option<String>,
56    #[serde(default)]
57    wan_source_channel: Option<String>,
58    #[serde(default)]
59    wan_key_fp: Option<String>,
60    #[serde(default)]
61    sender_same_account: Option<bool>,
62}
63#[derive(Deserialize)]
64pub(super) struct AckResp {
65    acknowledged: Vec<String>,
66    delivered_at: String,
67}
68
69/// Claim `body`'s injections for `agent_id` and deliver the ones this call
70/// won: the second half of [`sync_agent_reactive`], shared with
71/// [`sync_agents_batched`]. `agent_token` is the credential the pull used
72/// (`using_per_agent` when it is the agent's own), retried with the shared
73/// token on a rejection exactly as the pull is.
74#[allow(clippy::too_many_arguments)]
75pub(super) async fn claim_and_deliver(
76    base: &str,
77    agent_id: &str,
78    token: &str,
79    http: &reqwest::Client,
80    mstore: &Arc<Store>,
81    handler: &'static crate::backend::reactive::handler::ReactiveHandler,
82    body: PendingResp,
83    mut agent_token: String,
84    mut using_per_agent: bool,
85) -> AgentSyncOutcome {
86    if body.injections.is_empty() {
87        delivery_status::fetched(agent_id, 0, &body.expired);
88        return AgentSyncOutcome::Ok;
89    }
90
91    // Claim BEFORE delivering, not after: this is what prevents
92    // double-delivery when the same agent_id is concurrently registered by
93    // another AgentMux channel on this host (an intentionally-supported
94    // "two seats" workflow — see
95    // docs/specs/SPEC_MUXBUS_CROSS_CHANNEL_DUPLICATE_DELIVERY_2026_07_04.md).
96    // /reactive/ack now performs an atomic pending->delivered transition
97    // server-side; only injection ids that come back in `acknowledged` were
98    // actually won by this call and get delivered locally below. A
99    // previous version of this code delivered first and only acked
100    // successes afterward, which let two concurrent pollers both deliver
101    // the same injection.
102    let all_ids: Vec<String> = body.injections.iter().map(|inj| inj.id.clone()).collect();
103    let ack_url = format!("{}/reactive/ack", base);
104    let claimed: AckResp = loop {
105        // For telling a 409 from before a Take over apart (note_not_holder_since).
106        let claim_resp_started = std::time::Instant::now();
107        let claim_resp = match http
108            .post(&ack_url)
109            .header("Authorization", format!("Bearer {}", agent_token))
110            .header("X-Agent-ID", agent_id)
111            .header("X-Agent-Instance", crate::muxbus::wan_lease::instance_id())
112            .json(&serde_json::json!({ "injection_ids": all_ids }))
113            .send()
114            .await
115        {
116            Ok(r) => r,
117            Err(e) => {
118                tracing::warn!(agent_id = %agent_id, error = %e, "cloud_subscriber: claim request failed");
119                delivery_status::pull_failed(agent_id, "couldn't claim messages from the relay");
120                return AgentSyncOutcome::Ok; // nothing claimed — retried on the next wake/poll
121            }
122        };
123
124        if is_credential_rejected(claim_resp.status()) {
125            if using_per_agent {
126                // Same reasoning as the /reactive/pending branch above:
127                // invalidate and retry THIS claim immediately with the
128                // shared token, rather than reconnecting the whole session
129                // or leaving this agent's already-fetched pending
130                // injections unclaimed until an unrelated broadcast fires.
131                if claim_resp.status() == reqwest::StatusCode::FORBIDDEN {
132                    crate::muxbus::agent_credentials::invalidate_binding_mismatched_credential(agent_id, mstore);
133                } else {
134                    crate::muxbus::agent_credentials::invalidate_cached_token(agent_id, mstore);
135                }
136                tracing::warn!(
137                    agent_id = %agent_id,
138                    status = %claim_resp.status(),
139                    "cloud_subscriber: per-agent credential rejected on claim — invalidated, retrying with shared token",
140                );
141                agent_token = shared_token_now(mstore, token).await;
142                using_per_agent = false;
143                continue;
144            }
145            return shared_token_rejection_outcome(claim_resp.status(), agent_id);
146        }
147        if claim_resp.status() == reqwest::StatusCode::CONFLICT {
148            // The relay's lease fence on acks (the lease changed hands between
149            // this pull and this claim): same handling as a 409 on the pull —
150            // claim nothing, and fence the local holder (ReAgent P1 on #3746).
151            let body = claim_resp.json::<serde_json::Value>().await.unwrap_or_default();
152            if let Some(where_) = crate::muxbus::wan_lease::note_not_holder_since(agent_id, &body, claim_resp_started) {
153                crate::backend::agent_admission::fence_agent_named(agent_id, &where_);
154            }
155            return AgentSyncOutcome::Ok;
156        }
157        if !claim_resp.status().is_success() {
158            tracing::warn!(
159                status = %claim_resp.status(),
160                agent_id = %agent_id,
161                "cloud_subscriber: claim request non-2xx"
162            );
163            delivery_status::pull_failed(agent_id, format!("the relay answered {} to a message claim", claim_resp.status().as_u16()));
164            return AgentSyncOutcome::Ok; // nothing claimed — retried on the next wake/poll
165        }
166
167        break match claim_resp.json().await {
168            Ok(b) => b,
169            Err(e) => {
170                // The server processed the claim (status was 2xx) before this
171                // body failed to parse — some subset of `all_ids` may now be
172                // flipped to "delivered" server-side with no local delivery
173                // and no way for us to release them, since we don't know
174                // which ids succeeded or their delivered_at stamp (required
175                // by /reactive/release). We deliberately do NOT guess (e.g.
176                // via /reactive/status) and release whatever looks delivered:
177                // some of `all_ids` may have been legitimately claimed and
178                // delivered by a *different* concurrent poller (another
179                // channel/seat racing for the same agent_id), and blindly
180                // releasing those would reintroduce the exact duplicate-
181                // delivery bug this change exists to fix. Logging every
182                // affected id loudly is the safe tradeoff: rare silent
183                // message loss here, never a resurrected duplicate.
184                tracing::error!(
185                    agent_id = %agent_id,
186                    injection_ids = ?all_ids,
187                    error = %e,
188                    "cloud_subscriber: parse claim response failed — these injections may be claimed server-side with no local delivery and cannot be safely auto-recovered"
189                );
190                return AgentSyncOutcome::Ok;
191            }
192        };
193    };
194
195    let delivered_at = claimed.delivered_at;
196    let claimed_ids: std::collections::HashSet<String> = claimed.acknowledged.into_iter().collect();
197    let mut delivered_count = 0usize;
198
199    for inj in &body.injections {
200        if !claimed_ids.contains(&inj.id) {
201            // Another concurrent poller (this account's other channel/seat)
202            // already won this claim — expected under the "two seats"
203            // workflow, not an error.
204            continue;
205        }
206
207        // Verify a reagent-signed WAN message (see PendingInj's doc comment)
208        // before delivery. Only attempted when every one of the four
209        // signing fields is present — a partial set (e.g. a sig but no
210        // key_id) is treated the same as "not signed," not "signed but
211        // broken," since a legitimate sender always sends all four
212        // together. TRUST is unaffected either way (WAN stays
213        // unconditionally TRUST=network-claimed). TIER is only FORCED to
214        // sensitive when `reagent_verified` is `Some(false)` — a signature
215        // present but which failed to cryptographically verify (see
216        // InjectionRequest::reagent_verified,
217        // SPEC_JEKT_SENSITIVE_TIER_NARROWING_2026_08_15.md); as of that
218        // narrowing. `verify_trusted_reagent_jekt` yields `Some(true)` only
219        // under the trusted production key; a signature under any other
220        // registered key is `Some(false)`. `req.reagent_key_id` below must still be carried
221        // through from `inj` so the marker's `SIG=` label and the SIG=verified
222        // rule-1b exception render correctly (reagentx P0 on PR #2576 — it
223        // wasn't, so relaxation silently never activated for
224        // the desktop app's WS delivery path despite `reagent_verified`
225        // being computed correctly).
226        //
227        // Anti-replay (reagentx P1 on PR #2570): a captured, validly-signed
228        // (reagent_sig, reagent_key_id, reagent_msg_id, reagent_ts_secs,
229        // message) tuple could otherwise be resubmitted under a brand-new
230        // injection `id` indefinitely and still render SIG=verified — same
231        // class of gap host-tier's `verify_jekt_signature` closed with
232        // `JEKT_SIG_MAX_AGE_SECS`. `REAGENT_SIG_MAX_AGE_SECS` is wider
233        // (600s vs. 300s) because this is real network delivery, not a
234        // same-machine call — matches the review notifications' own
235        // delivery window, so a message
236        // that's still legitimately within its own staleness budget never
237        // spuriously fails signature freshness on top of that.
238        let reagent_verified = match (&inj.reagent_sig, &inj.reagent_key_id, &inj.reagent_msg_id, inj.reagent_ts_secs) {
239            (Some(sig), Some(key_id), Some(msg_id), Some(ts_secs)) => {
240                Some(
241                    reagent_sig_is_fresh(ts_secs, now_unix_secs())
242                        && agentmux_common::jekt_sign::verify_trusted_reagent_jekt(
243                            key_id,
244                            msg_id,
245                            inj.source_agent.as_deref().unwrap_or(""),
246                            agent_id,
247                            ts_secs,
248                            &inj.message,
249                            sig,
250                        ),
251                )
252            }
253            _ => None,
254        };
255
256        // W3-S (§2.3): a same-account agent's WAN signature, verified here —
257        // before transcript-request resolution, and against the POLLED
258        // agent, never a row field. Every "couldn't check" is `None` and the
259        // message is delivered as today; only an active failure is
260        // `Some(false)`.
261        let wan_row = crate::muxbus::wan_verify::CarriedRow {
262            wan_sig: inj.wan_sig.clone(),
263            wan_msg_id: inj.wan_msg_id.clone(),
264            wan_ts_secs: inj.wan_ts_secs,
265            wan_source_agent: inj.wan_source_agent.clone(),
266            wan_target_agent: inj.wan_target_agent.clone(),
267            wan_source_host: inj.wan_source_host.clone(),
268            wan_source_channel: inj.wan_source_channel.clone(),
269            wan_key_fp: inj.wan_key_fp.clone(),
270            sender_same_account: inj.sender_same_account,
271        };
272        let wan_store = crate::backend::storage::wan_identity::global();
273        let wan_verdict = match (&wan_store, &inj.wan_sig) {
274            (Some(wan), Some(_)) => match wan.instance_ensure(&crate::backend::reactive::registry::local_host_label()) {
275                Ok(own) => {
276                    let base_url = crate::muxbus::relay::rest_base_url();
277                    let dir_token = shared_token_now(mstore, token).await;
278                    let dir = crate::muxbus::wan_verify::Directory {
279                        base_url: &base_url,
280                        http,
281                        token: &dir_token,
282                        budget: &crate::muxbus::wan_verify::GLOBAL_BUDGET,
283                    };
284                    crate::muxbus::wan_verify::verify(
285                        wan,
286                        &own.instance_id,
287                        agent_id,
288                        inj.source_agent.as_deref().unwrap_or(""),
289                        &wan_row,
290                        &inj.message,
291                        now_unix_secs(),
292                        &dir,
293                    )
294                    .await
295                }
296                Err(e) => crate::muxbus::wan_verify::WanVerdict::unchecked("wan_no_local_instance", e.to_string()),
297            },
298            (None, Some(_)) => crate::muxbus::wan_verify::WanVerdict::unchecked("wan_no_local_store", "no wan.db attached"),
299            (_, None) => crate::muxbus::wan_verify::WanVerdict::default(),
300        };
301        // Every "couldn't check" renders exactly like an unsigned message
302        // (`TRUST=network-claimed`), so say which one happened at a level
303        // that is actually recorded. One line per WAN jekt.
304        if inj.wan_sig.is_some() {
305            let (verified, reason, detail) =
306                (wan_verdict.verified, wan_verdict.reason.unwrap_or(""), wan_verdict.detail.as_deref().unwrap_or(""));
307            if verified == Some(false) {
308                tracing::warn!(injection_id = %inj.id, source = ?inj.source_agent, reason, "wan verify: signature FAILED");
309            } else if !detail.is_empty() {
310                // A check that couldn't run for a known cause (directory
311                // refused or unreachable, no local store): the one line that
312                // reports it, at `warn` — `wan_verify::get_json` no longer
313                // logs each attempt.
314                tracing::warn!(
315                    injection_id = %inj.id,
316                    source = ?inj.source_agent,
317                    verified = ?verified,
318                    reason,
319                    detail,
320                    same_account = ?inj.sender_same_account,
321                    "wan verify: outcome"
322                );
323            } else {
324                tracing::info!(
325                    injection_id = %inj.id,
326                    source = ?inj.source_agent,
327                    verified = ?verified,
328                    reason,
329                    same_account = ?inj.sender_same_account,
330                    "wan verify: outcome"
331                );
332            }
333        } else if inj.reagent_sig.is_none() {
334            tracing::info!(injection_id = %inj.id, source = ?inj.source_agent, "wan verify: arrived without a WAN signature");
335        }
336
337        let mut req = InjectionRequest {
338            target_agent: agent_id.to_string(),
339            message: inj.message.clone(),
340            source_agent: inj.source_agent.clone(),
341            request_id: Some(inj.id.clone()),
342            priority: inj.priority.clone(),
343            jekt_tier: None,       // auto-detected from keywords
344            delivery_tier: Some("wan".to_string()),
345            forward_hops: 0,
346            reagent_verified,
347            reagent_key_id: inj.reagent_key_id.clone(),
348            wan_verified: wan_verdict.verified,
349            wan_instance: wan_verdict.instance.clone(),
350            wan_reason: wan_verdict.reason.map(str::to_string),
351            wan_detail: wan_verdict.detail.clone(),
352            ..Default::default()
353        };
354        // Phase C (muxspect Phase B/C conversation visibility,
355        // SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md): this WAN
356        // delivery path calls `Handler::inject_message` directly and never
357        // goes through `server/reactive.rs::handle_reactive_inject`/HTTP at
358        // all — without also resolving these two fields HERE, a
359        // WAN-delivered `transcript_request` would silently skip both the
360        // forced-TIER=sensitive rule and the escalate-forcing rule
361        // entirely, not just get a weaker version of them.
362        crate::server::reactive::resolve_transcript_request_tier_fields(mstore, &mut req);
363        // Marked only after every check above has read the message as sent.
364        let created_ms = inj.created_at.as_deref().and_then(delivery_status::notes::parse_created_at);
365        let late = delivery_status::notes::delayed_prefix(created_ms, now_ms());
366        if !late.is_empty() {
367            req.message = format!("{late}{}", req.message);
368        }
369        let delivery = handler.inject_message(req);
370        tracing::debug!(
371            injection_id = %inj.id,
372            agent_id = %agent_id,
373            success = delivery.success,
374            "cloud_subscriber: delivered injection"
375        );
376
377        // W3-S §2.5: only now, after it landed, is this signature "seen" —
378        // a release-and-redeliver after a failed delivery must not read as
379        // a replay.
380        if delivery.success {
381            delivered_count += 1;
382            if let Some(wan) = &wan_store {
383                crate::muxbus::wan_verify::record_delivered(wan, &wan_verdict, &wan_row, now_unix_secs());
384            }
385        }
386
387        if !delivery.success {
388            // We hold the claim but local delivery failed (e.g. agent not
389            // ready) — release it back to pending so it's retried, instead
390            // of silently dropping it now that claiming already marked it
391            // "delivered".
392            let release_url = format!("{}/reactive/release", base);
393            match http
394                .post(&release_url)
395                .header("Authorization", format!("Bearer {}", agent_token))
396                .header("X-Agent-ID", agent_id)
397                .json(&serde_json::json!({
398                    "injection_id": inj.id,
399                    "delivered_at": delivered_at,
400                }))
401                .send()
402                .await
403            {
404                Ok(r) if r.status().is_success() => {}
405                Ok(r) => {
406                    // Claim is stranded as "delivered" with no local
407                    // delivery and no retry until this is visible — log
408                    // loudly rather than discarding silently.
409                    tracing::warn!(
410                        injection_id = %inj.id,
411                        agent_id = %agent_id,
412                        status = %r.status(),
413                        "cloud_subscriber: release request non-2xx — injection stranded as delivered, message lost"
414                    );
415                }
416                Err(e) => {
417                    tracing::warn!(
418                        injection_id = %inj.id,
419                        agent_id = %agent_id,
420                        error = %e,
421                        "cloud_subscriber: release request failed — injection stranded as delivered, message lost"
422                    );
423                }
424            }
425        }
426    }
427
428    delivery_status::fetched(agent_id, delivered_count, &body.expired);
429    AgentSyncOutcome::Ok
430}