1pub(crate) mod policy;
5
6use std::collections::HashMap;
7use std::sync::{Arc, Mutex, Weak};
8use std::time::{Instant, SystemTime};
9
10use forge_primitives::SessionSlot;
11
12use crate::Workspace;
13use crate::crons::IST_OFFSET_MINUTES;
14use crate::mcp::peers::types::{MessageId, WrappedKind, WrappedPrompt, seat_name};
15use crate::protocol::Command;
16use crate::workspace::DYNAMIC_WORKER_RESTART_NOTE;
17use policy::{HealPolicy, SeatFacts};
18
19#[async_trait::async_trait]
22pub(crate) trait SeatRestart: Send + Sync {
23 async fn restart(&self, slot: &SessionSlot) -> Result<Option<String>, String>;
27}
28
29pub(crate) type Notify = Arc<dyn Fn(&SessionSlot, &str) + Send + Sync>;
32
33#[derive(Default)]
37struct Seat {
38 deaths: u32,
39 connected_at: Option<Instant>,
40 generation: u64,
41 stopped: bool,
45}
46
47pub(crate) struct Healing {
50 workspace: Weak<Workspace>,
51 restart: Arc<dyn SeatRestart>,
52 notify: Notify,
53 seats: Mutex<HashMap<SessionSlot, Seat>>,
54}
55
56impl Healing {
57 pub(crate) fn new(
58 workspace: Weak<Workspace>,
59 restart: Arc<dyn SeatRestart>,
60 notify: Notify,
61 ) -> Arc<Self> {
62 Arc::new(Self { workspace, restart, notify, seats: Mutex::new(HashMap::new()) })
63 }
64
65 pub(crate) fn for_workspace(workspace: &Arc<Workspace>) -> Arc<Self> {
68 let weak = Arc::downgrade(workspace);
69 let restart = Arc::new(WorkspaceRestart { workspace: Arc::downgrade(workspace) });
70 let notify_weak = Arc::downgrade(workspace);
71 let notify: Notify = Arc::new(move |slot: &SessionSlot, body: &str| {
72 let Some(workspace) = notify_weak.upgrade() else { return };
73 if let Err(error) = workspace.dispatch(lead_notice(slot, body.to_owned())) {
74 tracing::warn!(
75 target: "forge_workspace::healing",
76 event_name = "seat_heal_notice_failed",
77 slot = %slot.display(),
78 %error,
79 "the heal's notice to the lead could not be dispatched",
80 );
81 }
82 });
83 Self::new(weak, restart, notify)
84 }
85
86 pub(crate) fn note_death(self: &Arc<Self>, slot: &SessionSlot, diagnostic: &str) {
90 let Some(workspace) = self.workspace.upgrade() else { return };
91 let Some(project_key) = workspace.project_key_for_name(slot.project()) else { return };
92 let stopped = {
96 let mut seats = self.seats.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
97 std::mem::take(&mut seats.entry(slot.clone()).or_default().stopped)
98 };
99 if stopped || workspace.despawn_cleanup_pending(&project_key, slot.label()) {
100 self.forget(slot);
101 workspace.clear_heal_progress(slot);
102 return;
103 }
104 let policy = HealPolicy::from_config(&workspace.config_arc().sessions);
105 let facts = seat_facts(&workspace, slot, &project_key);
106 if !policy.is_eligible(facts) {
107 self.forget(slot);
112 workspace.clear_heal_progress(slot);
113 if !slot.is_lead() {
114 crate::workspace::transition_worker_to_failed(
119 &workspace,
120 &project_key,
121 slot,
122 Some(diagnostic.to_owned()),
123 );
124 }
125 return;
126 }
127 self.schedule(&workspace, slot, &project_key, diagnostic, SystemTime::now());
128 }
129
130 fn schedule(
135 self: &Arc<Self>,
136 workspace: &Arc<Workspace>,
137 slot: &SessionSlot,
138 project_key: &crate::ProjectKey,
139 diagnostic: &str,
140 died_at: SystemTime,
141 ) {
142 if workspace.is_shutting_down() {
146 self.forget(slot);
147 workspace.clear_heal_progress(slot);
148 return;
149 }
150 let policy = HealPolicy::from_config(&workspace.config_arc().sessions);
151 let (deaths, generation) = {
152 let mut seats = self.seats.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
153 let seat = seats.entry(slot.clone()).or_default();
154 if seat.connected_at.is_some_and(|at| policy.resets_after(at.elapsed())) {
155 seat.deaths = 0;
156 }
157 seat.deaths += 1;
158 seat.connected_at = None;
159 seat.stopped = false;
160 seat.generation += 1;
161 (seat.deaths, seat.generation)
162 };
163 let Some(wait) = policy.next_after(deaths) else {
164 workspace.clear_heal_progress(slot);
167 if !slot.is_lead() {
168 crate::workspace::transition_worker_to_failed(
173 workspace,
174 project_key,
175 slot,
176 Some(diagnostic.to_owned()),
177 );
178 }
179 tracing::warn!(
180 target: "forge_workspace::healing",
181 event_name = "seat_heal_exhausted",
182 slot = %slot.display(),
183 attempts = policy.attempts,
184 reason = %diagnostic,
185 "the seat died again after its restart budget; it is left failed",
186 );
187 if !slot.is_lead() {
188 (self.notify)(slot, &escalation_notice(slot, died_at, diagnostic, policy.attempts));
189 }
190 return;
191 };
192 let next_at = SystemTime::now() + wait;
193 workspace.set_heal_progress(slot, deaths, next_at);
194 if !slot.is_lead() {
195 workspace.mark_worker_restarting(project_key, slot);
196 }
197 tracing::info!(
198 target: "forge_workspace::healing",
199 event_name = "seat_heal_scheduled",
200 slot = %slot.display(),
201 attempt = deaths,
202 wait_ms = wait.as_millis(),
203 reason = %diagnostic,
204 "the seat died and forge is bringing it back",
205 );
206 let healing = Arc::clone(self);
207 let slot = slot.clone();
208 let diagnostic = diagnostic.to_owned();
209 let attempts = policy.attempts;
210 tokio::spawn(async move {
211 tokio::time::sleep(wait).await;
212 healing.fire(&slot, generation, died_at, &diagnostic, deaths, attempts).await;
213 });
214 }
215
216 pub(crate) fn note_connected(&self, slot: &SessionSlot) {
219 let mut seats = self.seats.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
220 let seat = seats.entry(slot.clone()).or_default();
221 seat.generation += 1;
222 seat.connected_at = Some(Instant::now());
223 seat.stopped = false;
224 drop(seats);
225 if let Some(workspace) = self.workspace.upgrade() {
226 workspace.clear_heal_progress(slot);
227 }
228 }
229
230 pub(crate) fn note_stopped(&self, slot: &SessionSlot) {
234 let mut seats = self.seats.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
235 let seat = seats.entry(slot.clone()).or_default();
236 seat.generation += 1;
237 seat.deaths = 0;
238 seat.connected_at = None;
239 seat.stopped = true;
240 drop(seats);
241 if let Some(workspace) = self.workspace.upgrade() {
242 workspace.clear_heal_progress(slot);
243 }
244 }
245
246 fn forget(&self, slot: &SessionSlot) {
249 let mut seats = self.seats.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
250 seats.remove(slot);
251 }
252
253 async fn fire(
257 self: Arc<Self>,
258 slot: &SessionSlot,
259 generation: u64,
260 died_at: SystemTime,
261 diagnostic: &str,
262 attempt: u32,
263 attempts: usize,
264 ) {
265 {
266 let seats = self.seats.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
267 if seats.get(slot).is_none_or(|seat| seat.generation != generation) {
268 return;
269 }
270 }
271 let Some(workspace) = self.workspace.upgrade() else { return };
272 if workspace.is_shutting_down() {
276 self.forget(slot);
277 workspace.clear_heal_progress(slot);
278 return;
279 }
280 if workspace.session_is_live(slot) {
283 self.forget(slot);
284 workspace.clear_heal_progress(slot);
285 return;
286 }
287 if slot.is_lead()
292 && matches!(
293 workspace.stored_seat_state(slot),
294 Some(crate::store::sessions::SeatState::Closed)
295 )
296 {
297 self.forget(slot);
298 workspace.clear_heal_progress(slot);
299 return;
300 }
301 if !slot.is_lead() {
302 let Some(project_key) = workspace.project_key_for_name(slot.project()) else {
303 self.forget(slot);
304 workspace.clear_heal_progress(slot);
305 return;
306 };
307 let workers = workspace.list_live_workers(&project_key);
308 if crate::mcp::workers::types::live_worker_with_label(&workers, slot.label()).is_some()
309 {
310 self.forget(slot);
311 workspace.clear_heal_progress(slot);
312 return;
313 }
314 if !matches!(workspace.stored_worker_row(&project_key, slot.label()), Ok(Some(_))) {
317 self.forget(slot);
318 workspace.clear_heal_progress(slot);
319 return;
320 }
321 workspace.clear_failed_worker(&project_key, slot.label());
324 }
325 let outcome = self.restart.restart(slot).await;
326 if !slot.is_lead() {
327 (self.notify)(
328 slot,
329 &attempt_notice(slot, died_at, diagnostic, attempt, attempts, &outcome),
330 );
331 }
332 if let Err(reason) = &outcome {
333 if let Some(project_key) = workspace.project_key_for_name(slot.project()) {
337 self.schedule(&workspace, slot, &project_key, reason, SystemTime::now());
338 }
339 }
340 }
341}
342
343fn seat_facts(
346 workspace: &Workspace,
347 slot: &SessionSlot,
348 project_key: &crate::ProjectKey,
349) -> SeatFacts {
350 let durable = if slot.is_lead() {
351 true
352 } else {
353 matches!(workspace.stored_worker_row(project_key, slot.label()), Ok(Some(_)))
354 };
355 SeatFacts {
356 durable,
357 connected_once: true,
361 stopping: false,
364 shutting_down: workspace.is_shutting_down(),
365 }
366}
367
368struct WorkspaceRestart {
371 workspace: Weak<Workspace>,
372}
373
374#[async_trait::async_trait]
375impl SeatRestart for WorkspaceRestart {
376 async fn restart(&self, slot: &SessionSlot) -> Result<Option<String>, String> {
377 let Some(workspace) = self.workspace.upgrade() else {
378 return Err("the workspace is gone".to_owned());
379 };
380 if slot.is_lead() {
381 let state = workspace.stored_seat_state(slot);
382 if matches!(state, Some(crate::store::sessions::SeatState::Closed)) {
385 return Ok(None);
386 }
387 let resume_kick = match state {
391 Some(crate::store::sessions::SeatState::InTurn) => {
392 Some(crate::workspace::LEAD_RESTART_NOTE.to_owned())
393 }
394 _ => None,
395 };
396 crate::spawn::handle_heal_project_spawn(
402 &workspace,
403 slot.project(),
404 forge_agent::client::SessionLaunchSettings { resume_kick, ..Default::default() },
405 )
406 .map(|()| None)
407 } else {
408 restart_worker(&workspace, slot).await
409 }
410 }
411}
412
413async fn restart_worker(
417 workspace: &Arc<Workspace>,
418 slot: &SessionSlot,
419) -> Result<Option<String>, String> {
420 let project_key = workspace
421 .project_key_for_name(slot.project())
422 .ok_or_else(|| format!("no configured project named '{}'", slot.project()))?;
423 let row = match workspace.stored_worker_row(&project_key, slot.label()) {
424 Ok(Some(row)) => row,
425 Ok(None) => return Err("its stored row is gone".to_owned()),
426 Err(error) => return Err(format!("its stored row could not be read: {error}")),
427 };
428 let lead = SessionSlot::lead(slot.org(), slot.project());
429 let facade = crate::mcp::workers::facade::ProdWorkerFacade::from_arc(workspace);
430 let kick = heal_resume_kick(&row);
431 facade
432 .spawn_worker(
433 &lead,
434 slot.label().to_owned(),
435 row.charter.unwrap_or_default(),
436 kick,
437 None,
440 row.interactive.unwrap_or(false),
441 true,
442 row.mcp_families,
443 row.cwd,
444 )
445 .await
446 .map(|reply| Some(reply.session_id))
447 .map_err(|error| error.message())
448}
449
450fn heal_resume_kick(row: &crate::store::sessions::SessionRecord) -> Option<String> {
456 if matches!(row.seat_state, Some(crate::store::sessions::SeatState::InTurn)) {
457 row.resume_kick.clone().or_else(|| Some(DYNAMIC_WORKER_RESTART_NOTE.to_owned()))
458 } else {
459 None
460 }
461}
462
463fn attempt_notice(
467 worker: &SessionSlot,
468 died_at: SystemTime,
469 diagnostic: &str,
470 attempt: u32,
471 attempts: usize,
472 outcome: &Result<Option<String>, String>,
473) -> String {
474 let seat = seat_name(worker);
475 let time = clock_text(died_at).unwrap_or_else(|| "an unrecorded time".to_owned());
476 let outcome = match outcome {
477 Ok(Some(session)) => {
478 format!("Restarted, attempt {attempt} of {attempts}, session {session}.")
479 }
480 Ok(None) => format!("Restarted, attempt {attempt} of {attempts}."),
481 Err(reason) => format!("Restart failed, attempt {attempt} of {attempts}: {reason}."),
482 };
483 format!("{seat} died at {time}: {diagnostic}. {outcome}")
484}
485
486fn escalation_notice(
489 worker: &SessionSlot,
490 died_at: SystemTime,
491 diagnostic: &str,
492 attempts: usize,
493) -> String {
494 let seat = seat_name(worker);
495 let time = clock_text(died_at).unwrap_or_else(|| "an unrecorded time".to_owned());
496 format!(
497 "{seat} died again at {time}: {diagnostic}. Left Failed after {attempts} restarts; it \
498 stays down until it is started again."
499 )
500}
501
502fn clock_text(at: SystemTime) -> Option<String> {
505 let since_epoch = at.duration_since(SystemTime::UNIX_EPOCH).ok()?;
506 let utc = chrono::DateTime::from_timestamp(
507 i64::try_from(since_epoch.as_secs()).ok()?,
508 since_epoch.subsec_nanos(),
509 )?;
510 let ist = utc.checked_add_signed(chrono::TimeDelta::minutes(IST_OFFSET_MINUTES))?;
511 Some(format!("{} IST ({}Z)", ist.format("%H:%M:%S"), utc.format("%H:%M:%S")))
512}
513
514fn lead_notice(worker: &SessionSlot, body: String) -> Command {
518 Command::DeliverPeerPrompt {
519 caller: worker.clone(),
520 target_project: worker.project().to_owned(),
521 wrapped: WrappedPrompt {
522 id: MessageId::mint(),
523 kind: WrappedKind::Message,
524 sender_name: seat_name(worker),
525 sender_org: worker.org().to_owned(),
526 body,
527 },
528 respond: None,
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use std::sync::{Arc, Mutex};
535 use std::time::Duration;
536
537 use forge_primitives::SessionSlot;
538
539 use super::*;
540
541 type Calls = Arc<Mutex<Vec<SessionSlot>>>;
542 type Notices = Arc<Mutex<Vec<(SessionSlot, String)>>>;
543
544 #[test]
548 fn a_heal_kicks_only_a_worker_left_mid_turn() {
549 use crate::store::sessions::{SeatState, SessionRecord};
550
551 let row = |state: Option<SeatState>| SessionRecord {
552 org: "Default".to_owned(),
553 project: "forge".to_owned(),
554 label: "steward".to_owned(),
555 session_id: Some("id-1".to_owned()),
556 charter: None,
557 kick: None,
558 resume_kick: Some("re-read the notes".to_owned()),
559 interactive: None,
560 seat_state: state,
561 is_git_repo: None,
562 mcp_families: None,
563 cwd: None,
564 };
565
566 assert_eq!(
567 heal_resume_kick(&row(Some(SeatState::InTurn))).as_deref(),
568 Some("re-read the notes"),
569 "an interrupted worker resumes with its own kick",
570 );
571 let mut bare = row(Some(SeatState::InTurn));
572 bare.resume_kick = None;
573 assert_eq!(
574 heal_resume_kick(&bare).as_deref(),
575 Some(DYNAMIC_WORKER_RESTART_NOTE),
576 "and falls back to the generic note",
577 );
578 assert_eq!(
579 heal_resume_kick(&row(Some(SeatState::Idle))),
580 None,
581 "a worker that was parked is resumed with nothing",
582 );
583 assert_eq!(
584 heal_resume_kick(&row(None)),
585 None,
586 "a row written before the field reads as parked, never as interrupted",
587 );
588 }
589
590 struct RecordingRestart {
593 calls: Calls,
594 outcome: Result<Option<String>, String>,
595 }
596
597 #[async_trait::async_trait]
598 impl SeatRestart for RecordingRestart {
599 async fn restart(&self, slot: &SessionSlot) -> Result<Option<String>, String> {
600 self.calls.lock().expect("recorder").push(slot.clone());
601 self.outcome.clone()
602 }
603 }
604
605 fn recorder(outcome: Result<Option<String>, String>) -> (Arc<dyn SeatRestart>, Calls) {
606 let calls: Calls = Arc::new(Mutex::new(Vec::new()));
607 let restart = Arc::new(RecordingRestart { calls: Arc::clone(&calls), outcome });
608 (restart, calls)
609 }
610
611 fn notifier() -> (Notify, Notices) {
613 let notices: Notices = Arc::new(Mutex::new(Vec::new()));
614 let seen = Arc::clone(¬ices);
615 let notify: Notify = Arc::new(move |slot: &SessionSlot, body: &str| {
616 seen.lock().expect("notices").push((slot.clone(), body.to_owned()));
617 });
618 (notify, notices)
619 }
620
621 fn seeded_worker() -> (Arc<Workspace>, SessionSlot) {
625 let dir = tempfile::tempdir().expect("tempdir");
626 let (workspace, _rx) = Workspace::testing_stub();
627 let db = crate::store::Db::open(&dir.path().join("db.redb")).expect("the store");
628 workspace.attach_store_for_test(db);
629 workspace.seed_test_project("forge", "/tmp/forge-heal-test");
630 let key = workspace
631 .list_projects()
632 .into_iter()
633 .find(|view| view.name == "forge")
634 .expect("the seeded project")
635 .key;
636 workspace.seed_test_worker_row(&key, "probe-heal");
637 let slot = SessionSlot::worker("TestOrg", "forge", "probe-heal");
640 (workspace, slot)
641 }
642
643 #[tokio::test(start_paused = true)]
647 async fn a_worker_death_schedules_one_resume() {
648 let (workspace, slot) = seeded_worker();
649 let (restart, calls) = recorder(Ok(None));
650 let (notify, _notices) = notifier();
651 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
652
653 healing.note_death(&slot, "the claude subprocess closed its output stream");
654
655 let progress = workspace.heal_progress(&slot).expect("the row draws restarting");
656 assert_eq!(progress.attempt, 1, "the first restart after the death");
657 assert!(calls.lock().expect("recorder").is_empty(), "nothing fires before the wait");
658
659 tokio::task::yield_now().await;
662 tokio::time::advance(Duration::from_secs(5)).await;
663 tokio::task::yield_now().await;
664 assert_eq!(
665 calls.lock().expect("recorder").as_slice(),
666 std::slice::from_ref(&slot),
667 "one resume, on the dead seat",
668 );
669 }
670
671 #[tokio::test(start_paused = true)]
675 async fn a_connected_seat_cancels_the_scheduled_attempt() {
676 let (workspace, slot) = seeded_worker();
677 let (restart, calls) = recorder(Ok(None));
678 let (notify, _notices) = notifier();
679 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
680
681 healing.note_death(&slot, "the subprocess closed");
682 tokio::task::yield_now().await;
685 healing.note_connected(&slot);
686 assert!(workspace.heal_progress(&slot).is_none(), "the row stops saying restarting");
687
688 tokio::time::advance(Duration::from_secs(60)).await;
689 tokio::task::yield_now().await;
690 assert!(calls.lock().expect("recorder").is_empty(), "the cancelled attempt never fires");
691 }
692
693 #[tokio::test(start_paused = true)]
697 async fn the_attempt_and_its_wait_move_with_each_death() {
698 let (workspace, slot) = seeded_worker();
699 let (restart, calls) = recorder(Ok(None));
700 let (notify, _notices) = notifier();
701 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
702
703 healing.note_death(&slot, "closed");
704 tokio::task::yield_now().await;
705 tokio::time::advance(Duration::from_secs(5)).await;
706 tokio::task::yield_now().await;
707 assert_eq!(calls.lock().expect("recorder").len(), 1);
708
709 healing.note_death(&slot, "closed again");
710 let progress = workspace.heal_progress(&slot).expect("the second attempt is recorded");
711 assert_eq!(progress.attempt, 2, "the second restart");
712 tokio::task::yield_now().await;
713 tokio::time::advance(Duration::from_secs(29)).await;
714 tokio::task::yield_now().await;
715 assert_eq!(calls.lock().expect("recorder").len(), 1, "30 s has not passed yet");
716 tokio::time::advance(Duration::from_secs(1)).await;
717 tokio::task::yield_now().await;
718 assert_eq!(calls.lock().expect("recorder").len(), 2, "the second attempt fires at 30 s");
719 }
720
721 #[tokio::test(start_paused = true)]
727 async fn a_worker_death_tells_the_lead_what_happened() {
728 let (workspace, slot) = seeded_worker();
729 let (restart, _calls) = recorder(Ok(Some("session-healed".to_owned())));
730 let (notify, notices) = notifier();
731 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
732
733 healing.note_death(&slot, "the claude subprocess closed its output stream");
734 assert!(notices.lock().expect("notices").is_empty(), "the outcome is not in yet");
735
736 tokio::task::yield_now().await;
737 tokio::time::advance(Duration::from_secs(5)).await;
738 tokio::task::yield_now().await;
739 tokio::task::yield_now().await;
740
741 let notices = notices.lock().expect("notices");
742 let (from, body) = notices.first().expect("the lead hears about the death");
743 assert_eq!(from, &slot, "the dying worker is the sender the envelope names");
744 assert!(body.contains("died at"), "the time the seat died: {body}");
745 assert!(body.contains(" IST ("), "IST first, UTC in parentheses: {body}");
746 assert!(body.contains('Z'), "the UTC half is spelled: {body}");
747 assert!(
748 body.contains("the claude subprocess closed its output stream"),
749 "the diagnostic: {body}",
750 );
751 assert!(body.contains("attempt 1 of 3"), "the attempt: {body}");
752 assert!(body.contains("session-healed"), "the outcome: {body}");
753 assert_eq!(notices.len(), 1, "one message per death");
754 }
755
756 #[tokio::test(start_paused = true)]
760 async fn a_spent_budget_escalates_once() {
761 let (workspace, slot) = seeded_worker();
762 let (restart, calls) = recorder(Ok(Some("session-healed".to_owned())));
766 let (notify, notices) = notifier();
767 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
768
769 for attempt in 1..=3 {
770 healing.note_death(&slot, "the subprocess closed");
771 tokio::task::yield_now().await;
772 let wait = match attempt {
773 1 => 5,
774 2 => 30,
775 _ => 120,
776 };
777 tokio::time::advance(Duration::from_secs(wait)).await;
778 tokio::task::yield_now().await;
779 tokio::task::yield_now().await;
780 }
781 assert_eq!(calls.lock().expect("recorder").len(), 3, "three restarts were attempted");
782
783 healing.note_death(&slot, "the subprocess closed again");
784 let notices = notices.lock().expect("notices");
785 let (_, body) = notices.last().expect("the escalation reaches the lead");
786 assert!(
787 body.contains("Left Failed after 3 restarts"),
788 "the seat is done for this run: {body}",
789 );
790 assert!(body.contains("the subprocess closed again"), "the last diagnostic: {body}");
791 assert!(!body.contains("attempt 4"), "the escalation is not a fourth attempt: {body}");
792 }
793
794 #[tokio::test(start_paused = true)]
798 async fn a_stopped_seat_is_not_healed() {
799 let (workspace, slot) = seeded_worker();
800 let (restart, calls) = recorder(Ok(None));
801 let (notify, notices) = notifier();
802 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
803
804 healing.note_stopped(&slot);
805 healing.note_death(&slot, "the subprocess closed");
806
807 tokio::task::yield_now().await;
808 tokio::time::advance(Duration::from_secs(60)).await;
809 tokio::task::yield_now().await;
810 assert!(calls.lock().expect("recorder").is_empty(), "the stop owns the seat");
811 assert!(notices.lock().expect("notices").is_empty(), "a stop's death is not news");
812 assert!(workspace.heal_progress(&slot).is_none(), "the row does not say restarting");
813 }
814
815 #[tokio::test(start_paused = true)]
819 async fn a_scheduled_heal_does_not_fire_after_a_shutdown_begins() {
820 let (workspace, slot) = seeded_worker();
821 let (restart, calls) = recorder(Ok(None));
822 let (notify, notices) = notifier();
823 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
824
825 healing.note_death(&slot, "the subprocess closed");
826 tokio::task::yield_now().await;
827 assert!(workspace.heal_progress(&slot).is_some(), "the attempt is scheduled");
828 let _ = workspace.shutdown_within(Duration::from_millis(10)).await;
829
830 tokio::time::advance(Duration::from_secs(60)).await;
831 tokio::task::yield_now().await;
832 assert!(calls.lock().expect("recorder").is_empty(), "the shutdown stops the attempt");
833 assert!(notices.lock().expect("notices").is_empty(), "and nothing is announced");
834 assert!(
835 workspace.heal_progress(&slot).is_none(),
836 "the row does not keep saying restarting"
837 );
838 }
839
840 #[tokio::test(start_paused = true)]
845 async fn an_ineligible_death_clears_the_heal_progress() {
846 let (workspace, slot) = seeded_worker();
847 let key = workspace.project_key_for_name("forge").expect("the fixture project");
848 let mut entry = worker_entry(&slot);
851 entry.status = forge_primitives::WorkerLiveness::Restarting;
852 workspace.insert_live_worker(&key, entry);
853 let (restart, _calls) = recorder(Ok(None));
854 let (notify, _notices) = notifier();
855 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
856
857 healing.note_death(&slot, "the subprocess closed");
858 assert!(workspace.heal_progress(&slot).is_some(), "the first attempt is scheduled");
859
860 assert!(workspace.delete_worker_row(&key, "probe-heal").expect("the row is deleted"));
864 healing.note_death(&slot, "the subprocess closed again");
865
866 assert!(workspace.heal_progress(&slot).is_none(), "the row stops saying restarting");
867 let entry = workspace
868 .list_live_workers(&key)
869 .into_iter()
870 .find(|entry| entry.slot == slot)
871 .expect("the row entry is kept");
872 assert_eq!(
873 entry.status,
874 forge_primitives::WorkerLiveness::Failed,
875 "and the entry is repainted failed rather than left restarting",
876 );
877 assert_eq!(
878 entry.diagnostic.as_deref(),
879 Some("the subprocess closed again"),
880 "with the death's own reason",
881 );
882 }
883
884 fn worker_entry(slot: &SessionSlot) -> crate::WorkerEntry {
886 crate::WorkerEntry {
887 label: slot.label().to_owned(),
888 charter: "charter".to_owned(),
889 slot: slot.clone(),
890 session_id: None,
891 status: forge_primitives::WorkerLiveness::Failed,
892 spawned_at: std::time::SystemTime::UNIX_EPOCH,
893 spawned_by: SessionSlot::lead(slot.org(), slot.project()),
894 needs_tag: false,
895 is_git_repo_at_spawn: false,
896 cwd: None,
897 diagnostic: None,
898 kick: None,
899 }
900 }
901
902 #[tokio::test(start_paused = true)]
905 async fn an_ad_hoc_death_schedules_nothing() {
906 let (workspace, _slot) = seeded_worker();
907 let (restart, calls) = recorder(Ok(None));
908 let (notify, notices) = notifier();
909 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
910 let ad_hoc = SessionSlot::worker("TestOrg", "forge", "no-row");
911
912 healing.note_death(&ad_hoc, "the subprocess closed");
913
914 assert!(workspace.heal_progress(&ad_hoc).is_none(), "nothing is scheduled");
915 assert!(notices.lock().expect("notices").is_empty(), "and nobody is told");
916 tokio::task::yield_now().await;
917 tokio::time::advance(Duration::from_secs(60)).await;
918 tokio::task::yield_now().await;
919 assert!(calls.lock().expect("recorder").is_empty(), "no restart was ever attempted");
920 }
921
922 #[tokio::test(start_paused = true)]
927 async fn a_refused_resume_burns_an_attempt_and_keeps_going() {
928 let (workspace, slot) = seeded_worker();
929 let (restart, calls) = recorder(Err("the worktree could not be re-created".to_owned()));
930 let (notify, notices) = notifier();
931 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
932
933 healing.note_death(&slot, "the subprocess closed");
934 tokio::task::yield_now().await;
935 tokio::time::advance(Duration::from_secs(5)).await;
936 tokio::task::yield_now().await;
937 tokio::task::yield_now().await;
938 assert_eq!(calls.lock().expect("recorder").len(), 1, "the first attempt was made");
939 let progress = workspace.heal_progress(&slot).expect("the second attempt is scheduled");
940 assert_eq!(progress.attempt, 2, "the refusal counted as a death");
941
942 tokio::task::yield_now().await;
943 tokio::time::advance(Duration::from_secs(30)).await;
944 tokio::task::yield_now().await;
945 tokio::task::yield_now().await;
946 tokio::task::yield_now().await;
947 tokio::time::advance(Duration::from_secs(120)).await;
948 tokio::task::yield_now().await;
949 tokio::task::yield_now().await;
950
951 assert_eq!(calls.lock().expect("recorder").len(), 3, "all three attempts were made");
952 let notices = notices.lock().expect("notices");
953 assert!(
954 notices.iter().any(|(_, body)| body.contains("Left Failed after 3 restarts")),
955 "the budget still escalates: {notices:?}",
956 );
957 assert!(
958 notices.iter().any(|(_, body)| body.contains("the worktree could not be re-created")),
959 "the refusal's own reason rides a notice: {notices:?}",
960 );
961 }
962
963 #[tokio::test(start_paused = true)]
966 async fn a_lead_death_tells_no_one() {
967 let (workspace, _worker) = seeded_worker();
968 let (restart, calls) = recorder(Ok(None));
969 let (notify, notices) = notifier();
970 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
971 let lead = SessionSlot::lead("TestOrg", "forge");
972
973 healing.note_death(&lead, "the subprocess closed");
974 tokio::task::yield_now().await;
975 tokio::time::advance(Duration::from_secs(5)).await;
976 tokio::task::yield_now().await;
977
978 assert_eq!(calls.lock().expect("recorder").len(), 1, "the lead still restarts");
979 assert!(notices.lock().expect("notices").is_empty(), "no peer message for a lead");
980 }
981
982 #[tokio::test(start_paused = true)]
987 async fn a_closed_lead_is_not_healed() {
988 let (workspace, _worker) = seeded_worker();
989 let (restart, calls) = recorder(Ok(None));
990 let (notify, notices) = notifier();
991 let healing = Healing::new(Arc::downgrade(&workspace), restart, notify);
992 let lead = SessionSlot::lead("TestOrg", "forge");
993 workspace.record_session_id(lead.org(), lead.project(), lead.label(), "lead-id");
994 workspace.record_seat_state(&lead, crate::store::sessions::SeatState::Idle);
995
996 healing.note_death(&lead, "the subprocess closed");
997 tokio::task::yield_now().await;
998 workspace.record_seat_state(&lead, crate::store::sessions::SeatState::Closed);
1001 tokio::time::advance(Duration::from_secs(60)).await;
1002 tokio::task::yield_now().await;
1003
1004 assert!(calls.lock().expect("recorder").is_empty(), "the close owns the seat");
1005 assert!(workspace.heal_progress(&lead).is_none(), "and the mark goes with it");
1006 assert!(notices.lock().expect("notices").is_empty(), "a close is not news");
1007 }
1008
1009 #[tokio::test]
1014 async fn the_heal_restart_of_a_closed_seat_reports_the_stop() {
1015 let (workspace, _worker) = seeded_worker();
1016 let lead = SessionSlot::lead("TestOrg", "forge");
1017 workspace.record_session_id(lead.org(), lead.project(), lead.label(), "lead-id");
1018 workspace.record_seat_state(&lead, crate::store::sessions::SeatState::Closed);
1019 let restart = WorkspaceRestart { workspace: Arc::downgrade(&workspace) };
1020
1021 let outcome = restart.restart(&lead).await;
1022
1023 assert!(
1024 matches!(outcome, Ok(None)),
1025 "the close ends the attempt without a failure: {outcome:?}",
1026 );
1027 }
1028
1029 #[test]
1032 fn the_notice_is_a_peer_message_to_the_projects_lead() {
1033 let worker = SessionSlot::worker("TestOrg", "forge", "probe-heal");
1034 let command = lead_notice(&worker, "the body".to_owned());
1035 let Command::DeliverPeerPrompt { caller, target_project, wrapped, .. } = command else {
1036 panic!("a worker notice must travel the peer path");
1037 };
1038 assert_eq!(caller, worker, "the dying worker is the sender");
1039 assert_eq!(target_project, "forge", "the project's own agent is the target");
1040 assert_eq!(wrapped.kind, WrappedKind::Message);
1041 assert_eq!(wrapped.sender_name, "forge/probe-heal");
1042 assert_eq!(wrapped.sender_org, "TestOrg");
1043 assert_eq!(wrapped.body, "the body");
1044 }
1045}