agentmux_srv/backend/blockcontroller/
session_recovery.rs1use std::sync::Arc;
42
43use crate::backend::eventbus::EventBus;
44use crate::backend::obj::{Block, MetaMapType};
45use crate::backend::storage::store::Store;
46
47pub const META_SESSION_ACTIVE_PID: &str = "session:active_pid";
49pub const META_SESSION_WAS_INTERRUPTED: &str = "session:was_interrupted";
51pub const META_SESSION_RESUME_FAILED: &str = "session:resume_failed";
54
55pub fn mark_active_pid(mstore: &Arc<Store>, block_id: &str, pid: u32) {
58 let mut meta = MetaMapType::new();
59 meta.insert(META_SESSION_ACTIVE_PID.to_string(), serde_json::json!(pid));
60 meta.insert(META_SESSION_WAS_INTERRUPTED.to_string(), serde_json::Value::Null);
62 let oref_str = format!("block:{}", block_id);
63 if let Err(e) = crate::server::service::update_object_meta(mstore, &oref_str, &meta) {
64 tracing::warn!(block_id = %block_id, error = %e, "session_recovery: failed to mark active pid");
65 }
66}
67
68pub fn mark_resume_failed(mstore: &Arc<Store>, event_bus: &Option<Arc<EventBus>>, block_id: &str) {
84 let mut meta = MetaMapType::new();
85 meta.insert(META_SESSION_RESUME_FAILED.to_string(), serde_json::json!(true));
86 let oref_str = format!("block:{}", block_id);
87 if let Err(e) = crate::server::service::update_object_meta(mstore, &oref_str, &meta) {
88 tracing::warn!(block_id = %block_id, error = %e, "session_recovery: failed to mark resume_failed");
89 return;
90 }
91 let Some(ref bus) = event_bus else {
92 return;
93 };
94 super::core::broadcast_block_update(mstore, bus, block_id);
95}
96
97pub fn clear_resume_failed(mstore: &Arc<Store>, event_bus: &Option<Arc<EventBus>>, block_id: &str) {
119 match mstore.get::<Block>(block_id) {
120 Ok(Some(block)) => {
121 let set = block
122 .meta
123 .get(META_SESSION_RESUME_FAILED)
124 .and_then(|v| v.as_bool())
125 .unwrap_or(false);
126 if !set {
127 return;
128 }
129 }
130 _ => return,
133 }
134
135 let mut meta = MetaMapType::new();
136 meta.insert(META_SESSION_RESUME_FAILED.to_string(), serde_json::Value::Null);
137 let oref_str = format!("block:{}", block_id);
138 if let Err(e) = crate::server::service::update_object_meta(mstore, &oref_str, &meta) {
139 tracing::warn!(block_id = %block_id, error = %e, "session_recovery: failed to clear resume_failed");
140 return;
141 }
142 tracing::info!(
143 block_id = %block_id,
144 "session_recovery: resume recovered — cleared the resume_failed disclosure"
145 );
146 let Some(ref bus) = event_bus else {
147 return;
148 };
149 super::core::broadcast_block_update(mstore, bus, block_id);
153}
154
155pub fn clear_active_pid(mstore: &Arc<Store>, block_id: &str) {
157 let mut meta = MetaMapType::new();
158 meta.insert(META_SESSION_ACTIVE_PID.to_string(), serde_json::Value::Null);
159 let oref_str = format!("block:{}", block_id);
160 if let Err(e) = crate::server::service::update_object_meta(mstore, &oref_str, &meta) {
161 tracing::warn!(block_id = %block_id, error = %e, "session_recovery: failed to clear active pid");
162 }
163}
164
165pub fn clear_active_pid_if_pid(mstore: &Arc<Store>, block_id: &str, expected_pid: u32) {
187 let block_id_owned = block_id.to_string();
188 let result = mstore.with_tx(|tx| {
189 let mut block = tx.must_get::<Block>(&block_id_owned)?;
190 let current = block
191 .meta
192 .get(META_SESSION_ACTIVE_PID)
193 .and_then(|v| v.as_u64())
194 .unwrap_or(0);
195 if current != expected_pid as u64 {
196 return Ok(false);
199 }
200 block.meta.remove(META_SESSION_ACTIVE_PID);
201 tx.update(&mut block)?;
202 Ok(true)
203 });
204 match result {
205 Ok(true) => {}
206 Ok(false) => {
207 tracing::info!(
208 block_id = %block_id,
209 expected_pid = expected_pid,
210 "session_recovery: active pid changed since this process registered — skipping clear"
211 );
212 }
213 Err(e) => {
214 tracing::warn!(block_id = %block_id, error = %e, "session_recovery: compare-and-clear active pid failed");
215 }
216 }
217}
218
219pub fn scan_orphans(mstore: &Arc<Store>) -> u32 {
224 let all_blocks = match mstore.get_all::<Block>() {
225 Ok(blocks) => blocks,
226 Err(e) => {
227 tracing::warn!(error = %e, "session_recovery: scan_orphans get_all failed");
228 return 0;
229 }
230 };
231
232 let mut count = 0u32;
233 for block in &all_blocks {
234 let view = block.meta.get("view").and_then(|v| v.as_str()).unwrap_or("");
236 if view != "agent" {
237 continue;
238 }
239
240 let pid = block
242 .meta
243 .get(META_SESSION_ACTIVE_PID)
244 .and_then(|v| v.as_u64())
245 .unwrap_or(0);
246 if pid == 0 {
247 continue;
248 }
249
250 let mut update = MetaMapType::new();
252 update.insert(META_SESSION_ACTIVE_PID.to_string(), serde_json::Value::Null);
253 update.insert(
254 META_SESSION_WAS_INTERRUPTED.to_string(),
255 serde_json::json!(true),
256 );
257 let oref_str = format!("block:{}", block.oid);
258 match crate::server::service::update_object_meta(mstore, &oref_str, &update) {
259 Ok(()) => {
260 count += 1;
261 tracing::info!(
262 block_id = %block.oid,
263 stale_pid = pid,
264 "session_recovery: marked orphaned session as interrupted"
265 );
266 }
267 Err(e) => {
268 tracing::warn!(
269 block_id = %block.oid,
270 error = %e,
271 "session_recovery: failed to mark orphan"
272 );
273 }
274 }
275 }
276
277 if count > 0 {
278 tracing::info!(count = count, "session_recovery: scan_orphans complete");
279 }
280 count
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::backend::obj::Block;
287
288 #[test]
292 fn test_scan_orphans_transfers_flag() {
293 let tmp = tempfile::tempdir().unwrap();
294 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
295
296 let orphan_id = "11111111-1111-1111-1111-111111111111";
298 let clean_id = "22222222-2222-2222-2222-222222222222";
299 let term_id = "33333333-3333-3333-3333-333333333333";
300
301 let mut orphan = Block {
303 oid: orphan_id.to_string(),
304 parentoref: String::new(),
305 version: 1,
306 runtimeopts: None,
307 stickers: None,
308 meta: {
309 let mut m = MetaMapType::new();
310 m.insert("view".to_string(), serde_json::json!("agent"));
311 m.insert(META_SESSION_ACTIVE_PID.to_string(), serde_json::json!(12345u32));
312 m
313 },
314 subblockids: None,
315 };
316 mstore.insert(&mut orphan).unwrap();
317
318 let mut clean = Block {
320 oid: clean_id.to_string(),
321 parentoref: String::new(),
322 version: 1,
323 runtimeopts: None,
324 stickers: None,
325 meta: {
326 let mut m = MetaMapType::new();
327 m.insert("view".to_string(), serde_json::json!("agent"));
328 m
329 },
330 subblockids: None,
331 };
332 mstore.insert(&mut clean).unwrap();
333
334 let mut nonagent = Block {
337 oid: term_id.to_string(),
338 parentoref: String::new(),
339 version: 1,
340 runtimeopts: None,
341 stickers: None,
342 meta: {
343 let mut m = MetaMapType::new();
344 m.insert("view".to_string(), serde_json::json!("term"));
345 m.insert(META_SESSION_ACTIVE_PID.to_string(), serde_json::json!(67890u32));
346 m
347 },
348 subblockids: None,
349 };
350 mstore.insert(&mut nonagent).unwrap();
351
352 let count = scan_orphans(&mstore);
353 assert_eq!(count, 1, "exactly one agent orphan should be flagged");
354
355 let after: Block = mstore.get(orphan_id).unwrap().unwrap();
357 assert!(
358 after.meta.get(META_SESSION_ACTIVE_PID).and_then(|v| v.as_u64()).unwrap_or(0) == 0,
359 "active_pid should be cleared"
360 );
361 assert_eq!(
362 after.meta.get(META_SESSION_WAS_INTERRUPTED).and_then(|v| v.as_bool()),
363 Some(true),
364 "was_interrupted should be true"
365 );
366
367 let clean_after: Block = mstore.get(clean_id).unwrap().unwrap();
369 assert!(clean_after.meta.get(META_SESSION_WAS_INTERRUPTED).is_none());
370
371 let term_after: Block = mstore.get(term_id).unwrap().unwrap();
373 assert_eq!(
374 term_after.meta.get(META_SESSION_ACTIVE_PID).and_then(|v| v.as_u64()),
375 Some(67890),
376 );
377 }
378
379 #[test]
380 fn test_mark_resume_failed_sets_the_flag() {
381 let tmp = tempfile::tempdir().unwrap();
382 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
383
384 let block_id = "44444444-4444-4444-4444-444444444444";
385 let mut block = Block {
386 oid: block_id.to_string(),
387 parentoref: String::new(),
388 version: 1,
389 runtimeopts: None,
390 stickers: None,
391 meta: {
392 let mut m = MetaMapType::new();
393 m.insert("view".to_string(), serde_json::json!("agent"));
394 m
395 },
396 subblockids: None,
397 };
398 mstore.insert(&mut block).unwrap();
399
400 mark_resume_failed(&mstore, &None, block_id);
401
402 let after: Block = mstore.get(block_id).unwrap().unwrap();
403 assert_eq!(
404 after.meta.get(META_SESSION_RESUME_FAILED).and_then(|v| v.as_bool()),
405 Some(true),
406 );
407 }
408
409 #[tokio::test]
416 async fn test_mark_resume_failed_broadcasts_waveobj_update() {
417 let tmp = tempfile::tempdir().unwrap();
418 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
419 let event_bus = Arc::new(EventBus::new());
420
421 let block_id = "77777777-7777-7777-7777-777777777777";
422 let mut block = Block {
423 oid: block_id.to_string(),
424 parentoref: String::new(),
425 version: 1,
426 runtimeopts: None,
427 stickers: None,
428 meta: {
429 let mut m = MetaMapType::new();
430 m.insert("view".to_string(), serde_json::json!("agent"));
431 m
432 },
433 subblockids: None,
434 };
435 mstore.insert(&mut block).unwrap();
436
437 let mut receivers = event_bus.register_ws("test-conn", "test-tab");
438
439 mark_resume_failed(&mstore, &Some(event_bus.clone()), block_id);
440
441 let msg = tokio::time::timeout(std::time::Duration::from_secs(2), receivers.priority.recv())
442 .await
443 .expect("timed out waiting for waveobj:update broadcast")
444 .expect("priority channel closed");
445 assert_eq!(msg.get("eventtype").and_then(|v| v.as_str()), Some("waveobj:update"));
446 assert_eq!(msg.get("oref").and_then(|v| v.as_str()), Some(format!("block:{}", block_id).as_str()));
447 let obj = msg.get("data").and_then(|d| d.get("obj")).expect("data.obj present");
448 assert_eq!(
449 obj.get("meta")
450 .and_then(|m| m.get(META_SESSION_RESUME_FAILED))
451 .and_then(|v| v.as_bool()),
452 Some(true),
453 "broadcast payload must carry the flag, not just trigger a generic refetch",
454 );
455 }
456
457 fn agent_block(block_id: &str) -> Block {
458 Block {
459 oid: block_id.to_string(),
460 parentoref: String::new(),
461 version: 1,
462 runtimeopts: None,
463 stickers: None,
464 meta: {
465 let mut m = MetaMapType::new();
466 m.insert("view".to_string(), serde_json::json!("agent"));
467 m
468 },
469 subblockids: None,
470 }
471 }
472
473 #[test]
479 fn test_clear_resume_failed_retracts_the_flag() {
480 let tmp = tempfile::tempdir().unwrap();
481 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
482
483 let block_id = "55555555-5555-5555-5555-555555555555";
484 let mut block = agent_block(block_id);
485 mstore.insert(&mut block).unwrap();
486
487 mark_resume_failed(&mstore, &None, block_id);
488 clear_resume_failed(&mstore, &None, block_id);
489
490 let after: Block = mstore.get(block_id).unwrap().unwrap();
491 assert_eq!(
492 after.meta.get(META_SESSION_RESUME_FAILED).and_then(|v| v.as_bool()),
493 None,
494 "a recovered resume must leave no resume_failed flag behind",
495 );
496 }
497
498 #[tokio::test]
502 async fn test_clear_resume_failed_is_silent_when_the_flag_was_never_set() {
503 let tmp = tempfile::tempdir().unwrap();
504 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
505 let event_bus = Arc::new(EventBus::new());
506
507 let block_id = "66666666-6666-6666-6666-666666666666";
508 let mut block = agent_block(block_id);
509 mstore.insert(&mut block).unwrap();
510 let version_before = mstore.get::<Block>(block_id).unwrap().unwrap().version;
511
512 let mut receivers = event_bus.register_ws("test-conn", "test-tab");
513
514 clear_resume_failed(&mstore, &Some(event_bus.clone()), block_id);
515
516 assert!(
517 tokio::time::timeout(
518 std::time::Duration::from_millis(200),
519 receivers.priority.recv(),
520 )
521 .await
522 .is_err(),
523 "no broadcast may fire when there was no flag to retract",
524 );
525 assert_eq!(
526 mstore.get::<Block>(block_id).unwrap().unwrap().version,
527 version_before,
528 "the block must not be rewritten when there was nothing to clear",
529 );
530 }
531
532 #[tokio::test]
536 async fn test_clear_resume_failed_broadcasts_waveobj_update() {
537 let tmp = tempfile::tempdir().unwrap();
538 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
539 let event_bus = Arc::new(EventBus::new());
540
541 let block_id = "88888888-8888-8888-8888-888888888888";
542 let mut block = agent_block(block_id);
543 mstore.insert(&mut block).unwrap();
544 mark_resume_failed(&mstore, &None, block_id);
545
546 let mut receivers = event_bus.register_ws("test-conn", "test-tab");
547
548 clear_resume_failed(&mstore, &Some(event_bus.clone()), block_id);
549
550 let msg = tokio::time::timeout(std::time::Duration::from_secs(2), receivers.priority.recv())
551 .await
552 .expect("timed out waiting for waveobj:update broadcast")
553 .expect("priority channel closed");
554 assert_eq!(msg.get("eventtype").and_then(|v| v.as_str()), Some("waveobj:update"));
555 let obj = msg.get("data").and_then(|d| d.get("obj")).expect("data.obj present");
556 assert_eq!(
557 obj.get("meta")
558 .and_then(|m| m.get(META_SESSION_RESUME_FAILED))
559 .and_then(|v| v.as_bool()),
560 None,
561 "the broadcast payload must show the flag gone, not still set",
562 );
563 }
564}