Skip to main content

forge_workspace/
healing.rs

1//! Auto-heal for dead seats: the supervisor that brings a durable lead or
2//! worker back when its subprocess closes while it should be up.
3
4pub(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/// How the supervisor brings a seat back. Production dispatches through
20/// the workspace; a test records the call instead of spawning a child.
21#[async_trait::async_trait]
22pub(crate) trait SeatRestart: Send + Sync {
23    /// Bring `slot` back. `Ok` carries the session it came back on when
24    /// one is known; `Err` is the refusal's reason, which the lead's
25    /// notice says instead of a restart.
26    async fn restart(&self, slot: &SessionSlot) -> Result<Option<String>, String>;
27}
28
29/// How the supervisor tells a project's lead about a worker. Production
30/// dispatches a peer message; a test records the prose.
31pub(crate) type Notify = Arc<dyn Fn(&SessionSlot, &str) + Send + Sync>;
32
33/// The supervisor's per-slot memory: deaths since the last stable run, when
34/// the current occupant connected, and a token that makes a superseded
35/// timer a no-op.
36#[derive(Default)]
37struct Seat {
38    deaths: u32,
39    connected_at: Option<Instant>,
40    generation: u64,
41    /// Set by a close or a despawn before the seat's death can reach the
42    /// supervisor. The next death consumes it and is not healed: the stop
43    /// owns the seat, whatever its subprocess did on the way out.
44    stopped: bool,
45}
46
47/// The auto-heal supervisor. One per workspace, built lazily the first time
48/// a death reaches it.
49pub(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    /// Production wiring: the restart dispatches through the workspace,
66    /// and a worker's notice is a peer message to its project's lead.
67    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    /// A seat that had been up died and its connection failed. Walks the
87    /// policy and either schedules the next attempt or leaves the seat
88    /// failed with its diagnostic.
89    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        // **The stop the seat's own door announced goes first.** A close or
93        // a despawn snaps the subprocess, whose death then arrives here; it
94        // is the stop's death, not a crash to heal.
95        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            // A shutdown, an ad-hoc seat or a heal turned off mid-flight
108            // owns this seat now; a stale attempt from an earlier death
109            // must not survive it, and the row must stop saying restarting
110            // when no attempt is coming.
111            self.forget(slot);
112            workspace.clear_heal_progress(slot);
113            if !slot.is_lead() {
114                // The entry a heal had marked Restarting would otherwise
115                // keep that word with nothing behind it. The progress is
116                // cleared above, so the transition is not skipped as an
117                // in-flight heal.
118                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    /// Count one death and either schedule the next attempt or leave the
131    /// seat failed and escalate. A subprocess death and an attempt whose
132    /// resume was refused both walk this path, so both spend the same
133    /// budget and carry their own diagnostic.
134    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        // **A shutdown owns the return**, including one that began after
143        // this death was accepted or while its attempt was running: nothing
144        // new is armed, and the boot re-spawn brings a durable seat back.
145        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            // The budget is spent: the seat stays failed with the last
165            // diagnostic, and this line is the record of the giving-up.
166            workspace.clear_heal_progress(slot);
167            if !slot.is_lead() {
168                // The row must stop saying Restarting even when no further
169                // death arrives to repaint it - a refused final attempt has
170                // none. The progress is already cleared, so the transition
171                // is not skipped as an in-flight heal.
172                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    /// An occupant connected on `slot`: any scheduled attempt is cancelled,
217    /// and the run's clock starts.
218    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    /// The seat was deliberately stopped (a close or a despawn). Its count
231    /// and its pending attempt go, and the stop is remembered so the
232    /// subprocess death the stop causes is not mistaken for a crash.
233    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    /// Forget one seat's attempts without touching the row: used when a
247    /// death turns out not to be heal-eligible.
248    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    /// A scheduled attempt fired. Re-check that the seat is still the one
254    /// that was scheduled, and still dead, then restart it and tell the
255    /// lead what happened.
256    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        // **A shutdown owns the return.** A timer armed before the wind-down
273        // must not write a fresh attempt over the teardown; the boot
274        // re-spawn is what brings a durable seat back after a restart.
275        if workspace.is_shutting_down() {
276            self.forget(slot);
277            workspace.clear_heal_progress(slot);
278            return;
279        }
280        // A reader opened the seat, a peer woke it, or a new worker took
281        // the label: the race the re-check exists for.
282        if workspace.session_is_live(slot) {
283            self.forget(slot);
284            workspace.clear_heal_progress(slot);
285            return;
286        }
287        // **A closed seat is the reader's stop, not a crash.** The close's
288        // `note_stopped` bumps the generation, so an attempt firing after it
289        // returns above; this is the window where the attempt was already
290        // past that check when the close landed.
291        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            // The row is the seat's durable handle: a close or despawn
315            // deleted it, so there is nothing to bring back.
316            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            // The failed tombstone goes before the resume appends its own
322            // entry, or the by-slot reads would move the tombstone instead.
323            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            // **A refused resume is a failed attempt, not a quiet stop.**
334            // The budget keeps burning, and the reason rides the next
335            // attempt and the escalation exactly as a death's does.
336            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
343/// The facts eligibility is decided on, read from the workspace's current
344/// state at each death, so a live config reload applies.
345fn 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        // A death reaches here from a seat that had connected, or from a
358        // failed resume of a seat already healing; both mean the seat was
359        // up and is now down, which is what this fact answers.
360        connected_once: true,
361        // The stop flag and the despawn cleanup are checked by `note_death`
362        // before this is built; what is left here is a non-issue.
363        stopping: false,
364        shutting_down: workspace.is_shutting_down(),
365    }
366}
367
368/// The production restart: the workspace's own spawn paths, which are the
369/// ones the boot re-spawn and the cron wake already use.
370struct 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            // **The close owns the seat**: this is the stop, not a failed
383            // attempt, so nothing is scheduled behind it.
384            if matches!(state, Some(crate::store::sessions::SeatState::Closed)) {
385                return Ok(None);
386            }
387            // **A death leaves the seat's state as it was.** The row's own
388            // `in_turn` earns the restart note, the same rule the boot wave
389            // reads, and the spawn is told it may not open a closed seat.
390            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            // The same handler `Command::SpawnProject` runs (the cron wake's
397            // own), called directly because the command bus drops the
398            // refusal and a failed attempt must count. It resumes the lead
399            // under its stored session and re-spawns its workers on
400            // connect.
401            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
413/// Resume one worker through the spawn its boot re-spawn uses: that path
414/// ensures the worktree it needs, so a missing one is re-created rather
415/// than refused.
416async 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            // The row's own resume kick stays the row's: this spawn only
438            // delivers the live kick.
439            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
450/// The kick a heal's worker resume carries.
451///
452/// **Only a worker whose turn was cut off gets one.** A worker that was
453/// idle when its subprocess died is resumed parked, the same as at a boot
454/// re-spawn; a crash must not put a seat back to work that was not working.
455fn 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
463/// The notice one worker death earns: the instant, the diagnostic, the
464/// attempt and what it did. The seat is named the way an envelope names
465/// it, so the lead can address it back.
466fn 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
486/// The notice a spent budget earns: the restarts are over and the seat
487/// stays down with the diagnostic that ended it.
488fn 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
502/// The instant as the notice spells it: IST first and UTC in parentheses,
503/// so the reader needs no arithmetic on either clock.
504fn 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
514/// The command that carries a worker's notice to its project's lead: the
515/// same envelope path `agents__send_message` uses, so a delivery to a
516/// sleeping lead parks and lands on wake.
517fn 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    /// **A heal kicks only the worker whose turn it interrupted.** A
545    /// worker that was parked when it died comes back parked; its own
546    /// `resume_kick` rides the kick only for a cut-off turn.
547    #[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    /// Records each restart, so a test watches the decision without
591    /// spawning a child, and answers every attempt with `outcome`.
592    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    /// Records the notice the supervisor would send a project's lead.
612    fn notifier() -> (Notify, Notices) {
613        let notices: Notices = Arc::new(Mutex::new(Vec::new()));
614        let seen = Arc::clone(&notices);
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    /// A workspace with one configured project and a stored row for its
622    /// worker `probe-heal`: the durable worker the supervisor may bring
623    /// back.
624    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        // The tempdir must outlive the store read below; the store holds
638        // its own file handle, so dropping the guard here is safe.
639        let slot = SessionSlot::worker("TestOrg", "forge", "probe-heal");
640        (workspace, slot)
641    }
642
643    /// A death schedules one resume at the settled wait, and the row draws
644    /// the attempt while it waits. Catches a supervisor that restarts
645    /// immediately, or one that never records the state the row reads.
646    #[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        // The spawned timer arms its sleep on its first poll; advance
660        // only after that, or it arms at the advanced instant instead.
661        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    /// A seat that connects before the timer fires cancels the attempt:
672    /// a reader opening the seat, or a peer message waking it, got there
673    /// first.
674    #[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        // Arm the timer before the connect cancels it, or the test would
683        // prove nothing about the cancel.
684        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    /// Each death moves the attempt and its wait: +5 s, then +30 s. Catches
694    /// a supervisor that counts deaths into the wrong slot or reuses the
695    /// first interval forever.
696    #[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    /// Every worker death reaches the project's lead once, with the time
722    /// in IST and UTC, the diagnostic, the attempt and what the attempt
723    /// did - and it goes out even when attempt 1 heals the seat. Catches a
724    /// supervisor silent on success, or one that tells the lead before the
725    /// attempt has an outcome.
726    #[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    /// A spent budget escalates once, naming the restarts and the
757    /// diagnostic that ended the run. Catches an escalation that reads as
758    /// one more attempt, or a notice per refused attempt instead.
759    #[tokio::test(start_paused = true)]
760    async fn a_spent_budget_escalates_once() {
761        let (workspace, slot) = seeded_worker();
762        // Each restart works and the child dies again straight after it:
763        // the crash loop, where the deaths - not the attempts - spend the
764        // budget.
765        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    /// **A death that follows a stop is the stop's.** A close or a despawn
795    /// kills the child itself, and the death that arrives afterwards must
796    /// not schedule anything - the stop already owns the seat.
797    #[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    /// **A shutdown owns the return.** A timer armed before the wind-down
816    /// must not write a fresh attempt over the teardown; the boot re-spawn
817    /// is what brings a durable seat back after a restart.
818    #[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    /// **A death that is no longer eligible takes the row's mark with it.**
841    /// A heal in flight whose seat stops being durable - its row gone, or
842    /// `heal` turned off in a reload - must not leave the row saying
843    /// restarting with no attempt coming.
844    #[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        // The registry entry a death leaves, marked the way the heal marks
849        // it between attempts.
850        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        // The row goes while the seat is healing, and the occupant the
861        // attempt brings up dies before connecting: the seat is no longer
862        // durable, so the death is refused.
863        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    /// The row entry a death leaves: not live, so a heal may take the label.
885    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    /// A seat with no durable row is not forge's to bring back: the death
903    /// schedules nothing at all, before any timer could re-check.
904    #[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    /// **A refused resume burns an attempt.** The seat does not stop at the
923    /// first refusal: the next attempt is scheduled carrying the refusal's
924    /// reason, and the budget still ends in the escalation. This is the
925    /// repository-that-is-gone case.
926    #[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    /// A lead has no lead above it: its death is drawn on the project row
964    /// and is nobody's peer message.
965    #[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    /// **A close that lands after the attempt was scheduled still stops
983    /// it.** The close bumps the generation, but an attempt already past
984    /// that check reads the row: a closed lead is the reader's stop, not a
985    /// crash, and the heal must not bring the project back.
986    #[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        // The close lands after the attempt was armed and past its own
999        // checks, which is the window the generation check cannot cover.
1000        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    /// **A close is the stop, not a failed attempt.** The restart reads the
1010    /// row again because the close can land after the attempt was armed, so
1011    /// a closed lead ends the attempt instead of burning the budget on a
1012    /// seat the reader took down.
1013    #[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    /// The notice is a peer message to the project, sent as the worker, so
1030    /// the lead reads it in its own chat and can answer it back.
1031    #[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}