Skip to main content

agentmux_srv/server/
jekt_held.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Replay of jekts held for an absent agent —
5//! `SPEC_DURABLE_JEKT_DELIVERY_2026_09_24.md` §2.3. The hold itself is in
6//! `reactive::deliver`; this delivers what was held once the agent is back.
7
8use std::time::Duration;
9
10use crate::backend::reactive::types::{InjectionRequest, JektTier};
11use crate::backend::storage::jekt_held::HeldJekt;
12
13use super::AppState;
14
15/// Deliveries per pass, so a backlog cannot monopolise the handler (and the
16/// first-contact spawn it may trigger) in one go.
17const REPLAY_PER_PASS: usize = 8;
18/// Failed deliveries to a present target before a held message is dropped.
19const MAX_ATTEMPTS: i64 = 20;
20const SWEEP_EVERY: Duration = Duration::from_secs(30);
21/// How often a target the spawn gate refused is re-checked for sign-in.
22const GATE_RECHECK_EVERY: Duration = Duration::from_secs(60);
23
24/// Whether a delivery error is the identity/credential spawn gate refusing to
25/// start the target (`run_agent_turn`'s "identity spawn gate: …"). That is
26/// recoverable — the receiver signs in — so the message is held, not dropped
27/// (SPEC_JEKT_DELIVERY_STATES_AND_MAILBOX_2026_10_01.md Phase 0 item 4, G4).
28pub(crate) fn is_spawn_gate_refusal(error: &str) -> bool {
29    error.starts_with("identity spawn gate")
30}
31
32/// Targets the spawn gate refused, with when the gate was last checked.
33///
34/// A delivery attempt to such a target is not free: `run_agent_turn` writes an
35/// error frame into its pane and raises a failure event each time. So the
36/// replay never attempts one blind; it first runs the gate on its own, which
37/// has no side effect beyond a log line, at most every [`GATE_RECHECK_EVERY`],
38/// and delivers once it passes. In memory: after a restart the first replay
39/// attempt finds the gate closed again and re-marks the target, one frame.
40fn needs_login() -> &'static std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>> {
41    static MAP: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>> =
42        std::sync::OnceLock::new();
43    MAP.get_or_init(Default::default)
44}
45
46/// Mark `uid` as refused by the spawn gate, checked just now.
47pub(crate) fn mark_needs_login(uid: &str) {
48    needs_login().lock().unwrap().insert(uid.to_string(), std::time::Instant::now());
49}
50
51/// What the replay should do about a target's sign-in, given when its gate was
52/// last checked (`None`: never refused).
53#[derive(Debug, PartialEq, Eq)]
54enum GateCheck {
55    /// Not refused before: deliver as usual.
56    Open,
57    /// Checked within [`GATE_RECHECK_EVERY`]: skip without checking.
58    Wait,
59    /// Due: run the gate.
60    Recheck,
61}
62
63fn gate_check_due(last: Option<std::time::Instant>, now: std::time::Instant) -> GateCheck {
64    match last {
65        None => GateCheck::Open,
66        Some(t) if now.duration_since(t) < GATE_RECHECK_EVERY => GateCheck::Wait,
67        Some(_) => GateCheck::Recheck,
68    }
69}
70
71/// Whether `uid` is still waiting for sign-in. Runs the spawn gate quietly
72/// (no pane frame, no failure event) when a recheck is due.
73async fn still_needs_login(state: &AppState, uid: &str) -> bool {
74    let last = needs_login().lock().unwrap().get(uid).copied();
75    match gate_check_due(last, std::time::Instant::now()) {
76        GateCheck::Open => return false,
77        GateCheck::Wait => return true,
78        GateCheck::Recheck => {}
79    }
80    let Some(block_id) = state.reactive_handler.block_for_uid(uid) else {
81        return true;
82    };
83    let (mstore, id_store, identity_store) =
84        (state.mstore.clone(), state.id_store.clone(), state.identity_store.clone());
85    let passed = tokio::task::spawn_blocking(move || {
86        crate::identity::inject_identity_env(mstore, id_store, identity_store, &block_id, &mut Default::default())
87            .is_ok()
88    })
89    .await
90    // A panicked check never opens the gate.
91    .unwrap_or(false);
92    if passed {
93        needs_login().lock().unwrap().remove(uid);
94        tracing::info!(target_uid = %uid, "durable jekt: signed in, delivering held messages");
95        return false;
96    }
97    mark_needs_login(uid);
98    true
99}
100
101/// Replay held jekts: once now, then whenever an agent registers and every
102/// 30 s. Installed after `AppState` exists.
103pub(crate) fn install(state: &AppState) {
104    let state = state.clone();
105    tokio::spawn(async move {
106        loop {
107            replay_pass(&state).await;
108            tokio::select! {
109                _ = crate::backend::reactive::held_jekt_wake().notified() => {}
110                _ = tokio::time::sleep(SWEEP_EVERY) => {}
111            }
112        }
113    });
114}
115
116/// How one replay attempt ended.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub(crate) enum ReplayOutcome {
119    Delivered,
120    /// Still absent, rate-limited, or its queue full — try again later, no
121    /// attempt counted.
122    Deferred,
123    /// Refused while present; counted.
124    Failed,
125    /// Refused `MAX_ATTEMPTS` times; dropped.
126    Dropped,
127}
128
129/// One pass: drop expired rows, then deliver to every target that is now
130/// registered, oldest first, at most [`REPLAY_PER_PASS`]. A target that is
131/// still absent costs a map lookup — no rate-limit token, no audit entry.
132pub(crate) async fn replay_pass(state: &AppState) -> Vec<(String, ReplayOutcome)> {
133    let mstore = state.mstore.clone();
134    let now = agentmux_common::time::now_ms();
135    let targets = tokio::task::spawn_blocking(move || {
136        match mstore.jekt_held_delete_expired(now) {
137            Ok(n) if n > 0 => {
138                for _ in 0..n {
139                    crate::backend::agent_resolve::record_uid_fallback("jekt.held_expired");
140                }
141                tracing::info!(expired = n, "durable jekt: held messages expired");
142            }
143            Ok(_) => {}
144            Err(e) => tracing::warn!(error = %e, "durable jekt: expiry failed"),
145        }
146        mstore.jekt_held_targets().unwrap_or_default()
147    })
148    .await
149    .unwrap_or_default();
150
151    // Rotate the starting target every pass, so targets that keep failing
152    // cannot take the budget ahead of a healthy one forever (Codex P2).
153    let mut targets = targets;
154    if !targets.is_empty() {
155        static ROTATION: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
156        let n = targets.len();
157        targets.rotate_left(ROTATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) % n);
158    }
159    let mut outcomes = Vec::new();
160    let mut spent = 0;
161    for uid in targets {
162        if spent >= REPLAY_PER_PASS {
163            break;
164        }
165        if !state.reactive_handler.has_uid_registration(&uid) {
166            continue;
167        }
168        if still_needs_login(state, &uid).await {
169            continue;
170        }
171        let mstore = state.mstore.clone();
172        let budget = REPLAY_PER_PASS - spent;
173        let rows = tokio::task::spawn_blocking(move || mstore.jekt_held_for_target(&uid, budget))
174            .await
175            .ok()
176            .and_then(Result::ok)
177            .unwrap_or_default();
178        for row in rows {
179            let id = row.request_id.clone();
180            let outcome = replay_one(state, row).await;
181            outcomes.push((id, outcome));
182            // A deferral (queue full, starting) applies to this target's
183            // later rows too: move on, spending no budget. A failure also
184            // ends this target's turn, spending one slot — so a target that
185            // keeps refusing takes at most one slot per pass and cannot
186            // starve the others (Codex P2 on #3632).
187            match outcome {
188                ReplayOutcome::Deferred => break,
189                ReplayOutcome::Failed | ReplayOutcome::Dropped => {
190                    spent += 1;
191                    break;
192                }
193                ReplayOutcome::Delivered => spent += 1,
194            }
195        }
196    }
197    outcomes
198}
199
200/// The request a held row is delivered as: addressed by the target's UID,
201/// with the verdicts it was accepted with — never re-verified (a signature
202/// is valid for five minutes) and never raised — and its original send time.
203pub(crate) fn request_from_held(row: &HeldJekt) -> InjectionRequest {
204    let non_empty = |s: &str| (!s.is_empty()).then(|| s.to_string());
205    let mut req = InjectionRequest {
206        target_agent: row.target_uid.clone(),
207        message: row.message.clone(),
208        source_agent: non_empty(&row.source_agent),
209        request_id: Some(row.request_id.clone()),
210        priority: non_empty(&row.priority),
211        jekt_tier: non_empty(&row.jekt_tier)
212            .and_then(|t| serde_json::from_value::<JektTier>(serde_json::Value::String(t)).ok()),
213        delivery_tier: Some("host".to_string()),
214        ..Default::default()
215    };
216    req.sig_verified = row.sig_verified;
217    // Not restored: only host-tier jekts are held (`hold_for_absent_target`)
218    // and reagent verification is WAN-only, so a held row never carries a
219    // real reagent verdict — and a replayed `Some(true)` without its key id
220    // would read as a failed verification.
221    req.reagent_verified = None;
222    req.lan_verified = row.lan_verified;
223    req.channel_verified = row.channel_verified;
224    req.is_transcript_request = row.is_transcript_request;
225    req.transcript_request_escalate_forced = row.transcript_request_escalate_forced;
226    req.audit_source_uid = row.audit_source_uid.clone();
227    req.held_sent_at_ms = Some(row.sent_at_ms);
228    req
229}
230
231async fn replay_one(state: &AppState, row: HeldJekt) -> ReplayOutcome {
232    // The transcript-request fields are restored as accepted, never
233    // recomputed: the replay addresses the target by UID, and the slug-keyed
234    // recompute would lose a forced escalation.
235    let req = request_from_held(&row);
236    let resp = state.reactive_handler.inject_held(req);
237    let mstore = state.mstore.clone();
238    let id = row.request_id.clone();
239    if resp.success {
240        let _ = tokio::task::spawn_blocking(move || mstore.jekt_held_delete(&id)).await;
241        crate::backend::agent_resolve::record_uid_fallback("jekt.held_delivered");
242        return ReplayOutcome::Delivered;
243    }
244    let error = resp.error.unwrap_or_default();
245    // Transient: still absent, rate-limited, its queue full, or the agent
246    // starting, restarting or stopping ("… try again shortly").
247    let transient = error.starts_with("agent not found")
248        || error.contains("rate limit")
249        || error.contains("queue is full")
250        || error.contains("try again shortly");
251    if transient {
252        return ReplayOutcome::Deferred;
253    }
254    // Not signed in: wait for sign-in, uncounted (G6 counted it, so a
255    // signed-out receiver lost its messages after 20 tries).
256    if is_spawn_gate_refusal(&error) {
257        mark_needs_login(&row.target_uid);
258        return ReplayOutcome::Deferred;
259    }
260    let attempts = tokio::task::spawn_blocking({
261        let mstore = mstore.clone();
262        let id = id.clone();
263        let error = error.clone();
264        // A store fault is not a failed delivery: never drop on it.
265        move || mstore.jekt_held_record_failure(&id, &error).unwrap_or(0)
266    })
267    .await
268    .unwrap_or(0);
269    if attempts >= MAX_ATTEMPTS {
270        let _ = tokio::task::spawn_blocking(move || mstore.jekt_held_delete(&id)).await;
271        crate::backend::agent_resolve::record_uid_fallback("jekt.held_failed");
272        tracing::warn!(target_uid = %row.target_uid, error = %error, "durable jekt: dropped after repeated failures");
273        return ReplayOutcome::Dropped;
274    }
275    ReplayOutcome::Failed
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use std::time::Instant;
282
283    #[test]
284    fn a_spawn_gate_refusal_is_recognised_and_nothing_else_is() {
285        assert!(is_spawn_gate_refusal("identity spawn gate: no credentials for claude: …"));
286        for other in ["agent not found: x", "rate limit exceeded", "persistent process not running", ""] {
287            assert!(!is_spawn_gate_refusal(other), "{other}");
288        }
289    }
290
291    /// A refused target is re-checked at most every GATE_RECHECK_EVERY, so the
292    /// 30 s replay never attempts a delivery blind (each would write an error
293    /// frame into the receiver's pane).
294    #[test]
295    fn the_sign_in_check_runs_at_most_once_a_minute() {
296        let now = Instant::now();
297        assert_eq!(gate_check_due(None, now), GateCheck::Open);
298        assert_eq!(gate_check_due(Some(now), now), GateCheck::Wait);
299        assert_eq!(gate_check_due(Some(now), now + Duration::from_secs(59)), GateCheck::Wait);
300        assert_eq!(gate_check_due(Some(now), now + GATE_RECHECK_EVERY), GateCheck::Recheck);
301    }
302}