agentmux_srv/server/
jekt_held.rs1use std::time::Duration;
9
10use crate::backend::reactive::types::{InjectionRequest, JektTier};
11use crate::backend::storage::jekt_held::HeldJekt;
12
13use super::AppState;
14
15const REPLAY_PER_PASS: usize = 8;
18const MAX_ATTEMPTS: i64 = 20;
20const SWEEP_EVERY: Duration = Duration::from_secs(30);
21const GATE_RECHECK_EVERY: Duration = Duration::from_secs(60);
23
24pub(crate) fn is_spawn_gate_refusal(error: &str) -> bool {
29 error.starts_with("identity spawn gate")
30}
31
32fn 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
46pub(crate) fn mark_needs_login(uid: &str) {
48 needs_login().lock().unwrap().insert(uid.to_string(), std::time::Instant::now());
49}
50
51#[derive(Debug, PartialEq, Eq)]
54enum GateCheck {
55 Open,
57 Wait,
59 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
71async 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 .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
101pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub(crate) enum ReplayOutcome {
119 Delivered,
120 Deferred,
123 Failed,
125 Dropped,
127}
128
129pub(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 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 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
200pub(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 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 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 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 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 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 #[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}