1use std::collections::HashMap;
4use std::ops::Range;
5use std::time::SystemTime;
6
7use forge_primitives::{SessionLifecycleState, SessionSlot, WorkerLiveness};
8use forge_workspace::{ProjectKey, Workspace};
9
10use crate::surface::ViewSurface;
11
12#[derive(Clone, serde::Serialize, serde::Deserialize)]
16pub struct AgentRow {
17 pub slot: SessionSlot,
18 pub label: String,
20 pub lifecycle: SessionLifecycleState,
21 pub heal_attempt: Option<u32>,
24 pub heal_attempts: Option<u32>,
27 pub has_background_work: bool,
28 pub pending: Option<PendingKind>,
29 pub pending_depth: usize,
32 pub pending_seq: u64,
37 pub last_activity: Option<SystemTime>,
38 pub reason: Option<String>,
42 pub failed_turn: Option<SystemTime>,
47 pub died: bool,
52 pub tree: Option<String>,
57}
58
59fn short_path(path: &std::path::Path) -> String {
63 if let Some(home) = dirs::home_dir()
64 && let Ok(rest) = path.strip_prefix(&home)
65 {
66 return format!("~/{}", rest.to_string_lossy());
67 }
68 path.to_string_lossy().into_owned()
69}
70
71pub use forge_workspace::PendingInteractionKind as PendingKind;
75
76pub struct Agents {
79 rows: Vec<AgentRow>,
80 spans: HashMap<ProjectKey, Range<usize>>,
83}
84
85impl Agents {
86 pub(super) fn collect(workspace: &Workspace) -> Self {
87 let labels = workspace.worker_labels_by_project();
88 let live = workspace.live_worker_states_by_project();
89 let mut rows = Vec::new();
90 let mut spans = HashMap::new();
91
92 for project in workspace.list_projects() {
93 let lead = SessionSlot::lead(&project.org, &project.name);
94 if workspace.domain_session_for(&lead).is_none()
103 && workspace.spawn_failure(&lead).is_none()
104 && !workspace.death_stands(&lead)
105 {
106 continue;
107 }
108 let start = rows.len();
109 rows.push(row_for(workspace, lead, "lead".to_owned(), None, None));
110 for label in labels.get(&project.key).into_iter().flatten() {
111 let slot = SessionSlot::worker(&project.org, &project.name, label);
112 let state = live
113 .get(&project.key)
114 .and_then(|states| states.iter().find(|s| &s.label == label));
115 rows.push(row_for(
116 workspace,
117 slot,
118 label.clone(),
119 state.map(|state| state.status),
120 state.and_then(|state| state.diagnostic.clone()),
121 ));
122 }
123 spans.insert(project.key.clone(), start..rows.len());
124 }
125
126 Self { rows, spans }
127 }
128
129 pub fn for_project(&self, key: &ProjectKey) -> &[AgentRow] {
132 self.spans.get(key).map_or(&[], |span| &self.rows[span.clone()])
135 }
136
137 pub fn all(&self) -> &[AgentRow] {
139 &self.rows
140 }
141}
142
143fn row_for(
146 workspace: &Workspace,
147 slot: SessionSlot,
148 label: String,
149 status: Option<WorkerLiveness>,
150 reason: Option<String>,
151) -> AgentRow {
152 let lifecycle = match status {
153 Some(WorkerLiveness::Spawning) => SessionLifecycleState::Spawning,
154 Some(WorkerLiveness::Failed) => SessionLifecycleState::Failed,
155 Some(WorkerLiveness::Restarting) => SessionLifecycleState::Restarting,
156 Some(WorkerLiveness::Running) | None => workspace.session_activity(&slot),
157 };
158 let heal = workspace.heal_progress(&slot);
159 AgentRow {
160 heal_attempt: heal.map(|progress| progress.attempt),
161 heal_attempts: heal.and_then(|_| workspace.heal_attempts()),
162 has_background_work: workspace.has_background_work(&slot),
163 last_activity: workspace.session_last_activity(&slot),
164 failed_turn: workspace.session_failed_turn(&slot),
165 died: workspace.death_stands(&slot),
166 pending: workspace.pending_interaction(&slot),
167 pending_depth: workspace.pending_interaction_depth(&slot),
168 pending_seq: workspace.pending_request_seq(&slot),
169 reason: reason
176 .or_else(|| workspace.spawn_failure(&slot))
177 .or_else(|| workspace.death_reason(&slot)),
178 tree: workspace.worker_tree_for_session(&slot).map(|root| short_path(&root)),
179 lifecycle,
180 label,
181 slot,
182 }
183}
184
185impl ViewSurface {
186 pub fn agents(&self) -> Agents {
188 Agents::collect(&self.workspace)
189 }
190
191 pub fn failed_turn(&self, slot: &SessionSlot) -> Option<SystemTime> {
198 self.workspace.session_failed_turn(slot)
199 }
200
201 pub fn seat_shown(&self, slot: &SessionSlot) -> bool {
216 self.workspace.spend_death(slot)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use std::sync::Arc;
223 use std::time::SystemTime;
224
225 use forge_primitives::{SessionLifecycleState, SessionSlot, WorkerLiveness};
226
227 use crate::surface::ViewSurface;
228 use crate::surface::agents::{PendingKind, short_path};
229
230 #[test]
235 fn every_pending_kind_serializes_to_its_wire_spelling() {
236 for (kind, spelling) in [
237 (PendingKind::Question, "question"),
238 (PendingKind::Permission, "permission"),
239 (PendingKind::SlackDraft, "slack_draft"),
240 (PendingKind::BrowserHandOff, "browser_hand_off"),
241 (PendingKind::TerminalHandOff, "terminal_hand_off"),
242 (PendingKind::DocumentHandOff, "document_hand_off"),
243 (PendingKind::SettingsWrite, "settings_write"),
244 (PendingKind::Vault, "vault"),
245 ] {
246 assert_eq!(
247 serde_json::to_value(kind).expect("a kind serializes"),
248 serde_json::Value::String(spelling.to_owned()),
249 "{kind:?}",
250 );
251 }
252 }
253
254 #[test]
260 fn agents_rows_a_projects_lead_first_then_its_workers() {
261 let (workspace, _dir) = crate::surface::testing::workspace();
262 let surface = ViewSurface::new(Arc::clone(&workspace));
263 let project =
264 surface.roster().project_named("forge").expect("configured project").key.clone();
265
266 let lead = SessionSlot::lead("TestOrg", "forge");
267 workspace.register_domain_session(lead.clone(), None);
268 workspace.seed_test_worker_row(&project, "probe-a");
269 workspace.register_domain_session(SessionSlot::worker("TestOrg", "forge", "probe-a"), None);
270
271 let agents = surface.agents();
272 let rows = agents.for_project(&project);
273
274 assert_eq!(rows.len(), 2, "the lead and the one worker label the registry holds");
275 assert_eq!(rows[0].slot, lead, "the lead's row comes first");
276 assert_eq!(rows[0].label, "lead", "the lead's row is labelled for what it is");
277 assert_eq!(rows[1].label, "probe-a", "then each worker under its own label");
278 assert_eq!(rows[1].slot, SessionSlot::worker("TestOrg", "forge", "probe-a"));
279 assert_eq!(agents.all().len(), 2, "all() carries every row for_project answers");
280 }
281
282 #[test]
287 fn a_project_with_no_live_session_contributes_no_rows() {
288 let (workspace, _dir) = crate::surface::testing::workspace();
289 let surface = ViewSurface::new(Arc::clone(&workspace));
290 let project =
291 surface.roster().project_named("forge").expect("configured project").key.clone();
292
293 workspace.seed_test_worker_row(&project, "probe-a");
294
295 let agents = surface.agents();
296
297 assert!(
298 agents.for_project(&project).is_empty(),
299 "a dormant project is the home's Start row, not an agent row",
300 );
301 assert!(agents.all().is_empty(), "and it contributes nothing to the fleet count");
302 }
303
304 #[test]
309 fn a_worker_row_carries_its_liveness_over_the_slot_derivation() {
310 let (workspace, _dir) = crate::surface::testing::workspace();
311 let surface = ViewSurface::new(Arc::clone(&workspace));
312 let project =
313 surface.roster().project_named("forge").expect("configured project").key.clone();
314 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
315
316 let spawning = SessionSlot::worker("TestOrg", "forge", "probe-spawn");
317 let failed = SessionSlot::worker("TestOrg", "forge", "probe-failed");
318 workspace.seed_test_worker_row(&project, "probe-spawn");
319 workspace.seed_test_worker_row(&project, "probe-failed");
320 workspace.insert_live_worker(&project, worker_row("probe-spawn", WorkerLiveness::Spawning));
321 workspace.insert_live_worker(&project, worker_row("probe-failed", WorkerLiveness::Failed));
322
323 let agents = surface.agents();
324 let rows = agents.for_project(&project);
325 let lifecycle_of = |slot: &SessionSlot| {
326 rows.iter()
327 .find(|row| &row.slot == slot)
328 .map(|row| row.lifecycle)
329 .expect("the worker has a row")
330 };
331
332 assert_eq!(
333 lifecycle_of(&spawning),
334 SessionLifecycleState::Spawning,
335 "a worker that has not connected is starting, not idle",
336 );
337 assert_eq!(
338 lifecycle_of(&failed),
339 SessionLifecycleState::Failed,
340 "a dead spawn is failed, and is kept only so the reason can be read",
341 );
342 }
343
344 #[test]
350 fn a_restarting_row_carries_its_attempt() {
351 let (workspace, _dir) = crate::surface::testing::workspace();
352 let surface = ViewSurface::new(Arc::clone(&workspace));
353 let project =
354 surface.roster().project_named("forge").expect("configured project").key.clone();
355 let lead = SessionSlot::lead("TestOrg", "forge");
356 workspace.register_domain_session(lead.clone(), None);
357 let worker = SessionSlot::worker("TestOrg", "forge", "probe-heal");
358 workspace.seed_test_worker_row(&project, "probe-heal");
359 workspace
360 .insert_live_worker(&project, worker_row("probe-heal", WorkerLiveness::Restarting));
361 let next_at = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_790_000_000);
362 workspace.set_heal_progress(&worker, 1, next_at);
363 workspace.set_heal_progress(&lead, 2, next_at);
364
365 let agents = surface.agents();
366 let rows = agents.for_project(&project);
367 let worker_row = rows.iter().find(|row| row.slot == worker).expect("the worker's row");
368 assert_eq!(
369 worker_row.lifecycle,
370 SessionLifecycleState::Restarting,
371 "a seat between attempts is restarting, not failed or asleep",
372 );
373 assert_eq!(worker_row.heal_attempt, Some(1), "the attempt the row names");
374
375 let lead_row = rows.iter().find(|row| row.slot == lead).expect("the lead's row");
376 assert_eq!(
377 lead_row.lifecycle,
378 SessionLifecycleState::Restarting,
379 "a lead has no worker entry, so its row reads the heal state from the workspace",
380 );
381 assert_eq!(lead_row.heal_attempt, Some(2));
382 }
383
384 #[test]
388 fn a_persisted_worker_label_with_no_live_entry_reads_asleep() {
389 let (workspace, _dir) = crate::surface::testing::workspace();
390 let surface = ViewSurface::new(Arc::clone(&workspace));
391 let project =
392 surface.roster().project_named("forge").expect("configured project").key.clone();
393 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
394 workspace.seed_test_worker_row(&project, "probe-gone");
395
396 let agents = surface.agents();
397 let rows = agents.for_project(&project);
398
399 let row = rows
400 .iter()
401 .find(|row| row.label == "probe-gone")
402 .expect("the persisted label still has a row");
403 assert_eq!(
404 row.lifecycle,
405 SessionLifecycleState::Sleeping,
406 "no session behind it is asleep, whichever liveness the registry last held",
407 );
408 }
409
410 #[test]
414 fn a_row_carries_what_its_session_is_waiting_on() {
415 let (workspace, _dir) = crate::surface::testing::workspace();
416 let surface = ViewSurface::new(Arc::clone(&workspace));
417 let project =
418 surface.roster().project_named("forge").expect("configured project").key.clone();
419 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
420
421 let asked = SessionSlot::worker("TestOrg", "forge", "probe-asked");
422 let quiet = SessionSlot::worker("TestOrg", "forge", "probe-quiet");
423 workspace.seed_test_worker_row(&project, "probe-asked");
424 workspace.seed_test_worker_row(&project, "probe-quiet");
425 workspace.seed_test_pending_interaction(&asked, PendingKind::Question);
426
427 let agents = surface.agents();
428 let rows = agents.for_project(&project);
429 let row_of = |slot: &SessionSlot| {
430 rows.iter().find(|row| &row.slot == slot).expect("the session has a row")
431 };
432
433 assert_eq!(
434 row_of(&asked).pending,
435 Some(PendingKind::Question),
436 "the row names the question its session is held on",
437 );
438 assert_eq!(
439 row_of(&quiet).pending,
440 None,
441 "a session holding nothing says so rather than borrowing its neighbour's ask",
442 );
443 }
444
445 #[test]
450 fn a_row_carries_the_reason_the_core_recorded() {
451 let (workspace, _dir) = crate::surface::testing::workspace();
452 let surface = ViewSurface::new(Arc::clone(&workspace));
453 let project =
454 surface.roster().project_named("forge").expect("configured project").key.clone();
455 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
456 workspace.seed_test_worker_row(&project, "probe-dead");
457 workspace.seed_test_worker_row(&project, "probe-live");
458
459 let mut dead = worker_row("probe-dead", WorkerLiveness::Failed);
460 dead.diagnostic = Some("OAuth token expired; run /login to retry".to_owned());
461 workspace.insert_live_worker(&project, dead);
462 workspace.insert_live_worker(&project, worker_row("probe-live", WorkerLiveness::Running));
463
464 let agents = surface.agents();
465 let rows = agents.for_project(&project);
466 let reason_of = |label: &str| {
467 rows.iter().find(|row| row.label == label).expect("the row exists").reason.clone()
468 };
469
470 assert_eq!(
471 reason_of("probe-dead").as_deref(),
472 Some("OAuth token expired; run /login to retry"),
473 "a failed row carries the reason the core recorded",
474 );
475 assert_eq!(reason_of("probe-live"), None, "a live worker has no failure to explain");
476 assert_eq!(
477 reason_of("lead"),
478 None,
479 "a lead that has not failed carries none, the same as a live worker",
480 );
481 }
482
483 #[test]
487 fn a_row_carries_the_failed_turn_the_core_recorded() {
488 let (workspace, _dir) = crate::surface::testing::workspace();
489 let surface = ViewSurface::new(Arc::clone(&workspace));
490 let project =
491 surface.roster().project_named("forge").expect("configured project").key.clone();
492 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
493
494 let failed = SessionSlot::worker("TestOrg", "forge", "probe-failed-turn");
495 let quiet = SessionSlot::worker("TestOrg", "forge", "probe-quiet-turn");
496 workspace.seed_test_worker_row(&project, "probe-failed-turn");
497 workspace.seed_test_worker_row(&project, "probe-quiet-turn");
498 workspace.register_domain_session(failed.clone(), None);
499 let at = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_800_000_000);
500 workspace.record_failed_turn_for_test(&failed, at);
501
502 let agents = surface.agents();
503 let rows = agents.for_project(&project);
504 let failed_of = |slot: &SessionSlot| {
505 rows.iter().find(|row| &row.slot == slot).expect("the row exists").failed_turn
506 };
507
508 assert_eq!(failed_of(&failed), Some(at), "the row carries the failed turn's instant");
509 assert_eq!(failed_of(&quiet), None, "a seat that never failed carries none");
510 }
511
512 #[test]
516 fn a_row_names_the_tree_a_worker_was_pointed_at() {
517 let (workspace, _dir) = crate::surface::testing::workspace();
518 let surface = ViewSurface::new(Arc::clone(&workspace));
519 let project =
520 surface.roster().project_named("forge").expect("configured project").key.clone();
521 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
524 let tree = tempfile::tempdir().expect("worker tree");
528 let root = std::fs::canonicalize(tree.path()).expect("canonical tree");
529 std::fs::create_dir_all(root.join(".claude").join("worktrees").join("probe-moved"))
530 .expect("worker worktree");
531 let entry = |label: &str, slot: &SessionSlot, cwd: Option<std::path::PathBuf>| {
532 forge_workspace::WorkerEntry {
533 label: label.to_owned(),
534 charter: "c".to_owned(),
535 slot: slot.clone(),
536 session_id: None,
537 status: forge_primitives::WorkerLiveness::Running,
538 spawned_at: std::time::SystemTime::UNIX_EPOCH,
539 spawned_by: SessionSlot::lead("TestOrg", "forge"),
540 needs_tag: false,
541 is_git_repo_at_spawn: true,
542 cwd,
543 diagnostic: None,
544 kick: None,
545 }
546 };
547 let moved = SessionSlot::worker("TestOrg", "forge", "probe-moved");
548 workspace.insert_live_worker(&project, entry("probe-moved", &moved, Some(root.clone())));
549 workspace.seed_test_worker_row_at(&project, "probe-moved", &root.to_string_lossy(), true);
550 let at_home = SessionSlot::worker("TestOrg", "forge", "probe-at-home");
551 workspace.insert_live_worker(&project, entry("probe-at-home", &at_home, None));
552 workspace.seed_test_worker_row(&project, "probe-at-home");
553
554 let agents = surface.agents();
555 let tree_of = |slot: &SessionSlot| {
556 agents
557 .for_project(&project)
558 .iter()
559 .find(|row| &row.slot == slot)
560 .expect("the row exists")
561 .tree
562 .clone()
563 };
564
565 assert_eq!(
566 tree_of(&moved).as_deref(),
567 Some(root.to_string_lossy().as_ref()),
568 "the worker's own tree is named",
569 );
570 assert_eq!(
571 tree_of(&at_home),
572 None,
573 "a worker in its project's own directory names no tree of its own",
574 );
575 }
576
577 #[test]
581 fn a_named_tree_is_shortened_at_the_home() {
582 let home = dirs::home_dir().expect("home dir");
583 assert_eq!(short_path(&home.join("Projects").join("nazar")), "~/Projects/nazar");
584 assert_eq!(short_path(std::path::Path::new("/opt/trees/nazar")), "/opt/trees/nazar");
585 }
586
587 #[test]
593 fn a_lead_whose_session_died_keeps_a_failed_row() {
594 let (workspace, _dir) = crate::surface::testing::workspace();
595 let surface = ViewSurface::new(Arc::clone(&workspace));
596 let project =
597 surface.roster().project_named("forge").expect("configured project").key.clone();
598 let lead = SessionSlot::lead("TestOrg", "forge");
599 workspace
600 .record_spawn_failure_for_test(&lead, "the claude subprocess closed its output stream");
601
602 let agents = surface.agents();
603 let row = agents
604 .for_project(&project)
605 .first()
606 .expect("a crashed seat still has a row, with no domain session behind it");
607
608 assert_eq!(row.slot, lead, "the row is the seat that died");
609 assert_eq!(
610 row.lifecycle,
611 SessionLifecycleState::Failed,
612 "a dead session reads failed rather than absent",
613 );
614 assert_eq!(
615 row.reason.as_deref(),
616 Some("the claude subprocess closed its output stream"),
617 "and the row carries why",
618 );
619 }
620
621 #[test]
628 fn a_recovered_seat_reads_live_and_still_carries_its_death() {
629 let (workspace, _dir) = crate::surface::testing::workspace();
630 let surface = ViewSurface::new(Arc::clone(&workspace));
631 let project =
632 surface.roster().project_named("forge").expect("configured project").key.clone();
633 let lead = SessionSlot::lead("TestOrg", "forge");
634 workspace.register_domain_session(lead.clone(), None);
635 workspace.record_death_for_test(
636 &lead,
637 "dead-occupant",
638 "the claude subprocess closed its output stream",
639 );
640
641 let agents = surface.agents();
642 let row = agents.for_project(&project).first().expect("the recovered seat has its row");
643
644 assert_ne!(
645 row.lifecycle,
646 SessionLifecycleState::Failed,
647 "a recovered seat reading Failed is the lie, mirrored",
648 );
649 assert!(row.died, "and the death nobody was shown still rides");
650 assert_eq!(
651 row.reason.as_deref(),
652 Some("the claude subprocess closed its output stream"),
653 "with the reason it left, which the cleared failure no longer carries",
654 );
655
656 assert!(surface.seat_shown(&lead), "the show takes the trace");
658 let agents = surface.agents();
659 let row = agents.for_project(&project).first().expect("the row is still there");
660 assert!(!row.died, "the spent death is off the row");
661 assert_eq!(row.reason, None, "and its reason with it");
662 }
663
664 #[test]
668 fn a_seat_that_crashed_and_is_still_down_reads_failed() {
669 let (workspace, _dir) = crate::surface::testing::workspace();
670 let surface = ViewSurface::new(Arc::clone(&workspace));
671 let project =
672 surface.roster().project_named("forge").expect("configured project").key.clone();
673 let lead = SessionSlot::lead("TestOrg", "forge");
674 workspace
675 .record_spawn_failure_for_test(&lead, "the claude subprocess closed its output stream");
676 workspace.record_death_for_test(
677 &lead,
678 "dead-occupant",
679 "the claude subprocess closed its output stream",
680 );
681
682 let agents = surface.agents();
683 let row = agents.for_project(&project).first().expect("the crashed seat has its row");
684
685 assert_eq!(
686 row.lifecycle,
687 SessionLifecycleState::Failed,
688 "a seat nothing brought back reads failed",
689 );
690 assert!(row.died, "and the death it has not been shown rides");
691 assert_eq!(
692 row.reason.as_deref(),
693 Some("the claude subprocess closed its output stream"),
694 "with the reason it died with",
695 );
696 }
697
698 #[test]
704 fn a_seat_with_no_session_and_a_standing_death_keeps_its_row() {
705 let (workspace, _dir) = crate::surface::testing::workspace();
706 let surface = ViewSurface::new(Arc::clone(&workspace));
707 let project =
708 surface.roster().project_named("forge").expect("configured project").key.clone();
709 let lead = SessionSlot::lead("TestOrg", "forge");
710 workspace.record_death_for_test(
713 &lead,
714 "dead-occupant",
715 "the claude subprocess closed its output stream",
716 );
717
718 let agents = surface.agents();
719 let row = agents
720 .for_project(&project)
721 .first()
722 .expect("a standing death keeps the row with nothing else behind it");
723
724 assert!(row.died, "the death is on the row");
725 assert_eq!(
726 row.reason.as_deref(),
727 Some("the claude subprocess closed its output stream"),
728 "with the reason it left",
729 );
730 assert_eq!(
731 row.lifecycle,
732 SessionLifecycleState::Sleeping,
733 "and the lifecycle reads the seat that is not there",
734 );
735 }
736
737 #[test]
741 fn a_lead_carries_the_reason_its_spawn_failed() {
742 let (workspace, _dir) = crate::surface::testing::workspace();
743 let surface = ViewSurface::new(Arc::clone(&workspace));
744 let project =
745 surface.roster().project_named("forge").expect("configured project").key.clone();
746 let lead = SessionSlot::lead("TestOrg", "forge");
747 workspace.register_domain_session(lead.clone(), None);
748 workspace.record_spawn_failure_for_test(&lead, "the subprocess exited");
749
750 let agents = surface.agents();
751 let row = agents.for_project(&project).first().expect("the lead's row");
752
753 assert_eq!(
754 row.lifecycle,
755 SessionLifecycleState::Failed,
756 "a spawn that failed is failed rather than idle",
757 );
758 assert_eq!(row.reason.as_deref(), Some("the subprocess exited"), "and the row carries why");
759 }
760
761 #[test]
767 fn closing_a_failed_lead_dismisses_its_spawn_failure() {
768 let (workspace, _dir) = crate::surface::testing::workspace();
769 let surface = ViewSurface::new(Arc::clone(&workspace));
770 let project =
771 surface.roster().project_named("forge").expect("configured project").key.clone();
772 let lead = SessionSlot::lead("TestOrg", "forge");
773 workspace.record_spawn_failure_for_test(&lead, "the account was rate limited");
774
775 let agents = surface.agents();
776 assert_eq!(
777 agents.for_project(&project).first().map(|row| row.lifecycle),
778 Some(SessionLifecycleState::Failed),
779 "the failed spawn is the row the close has to dismiss",
780 );
781
782 workspace
783 .dispatch(forge_workspace::Command::CloseSession { session_key: lead.clone() })
784 .expect("a close on a failed lead is dispatched");
785
786 assert_eq!(
787 workspace.session_activity(&lead),
788 SessionLifecycleState::Sleeping,
789 "the dismissed seat reads asleep rather than failed",
790 );
791 let agents = surface.agents();
792 assert!(
793 agents.for_project(&project).is_empty(),
794 "and it contributes no row: no session, no failure, no death",
795 );
796 }
797
798 #[test]
803 fn closing_a_stranded_failed_worker_dismisses_its_failure_and_keeps_its_row() {
804 let (workspace, _dir) = crate::surface::testing::workspace();
805 let surface = ViewSurface::new(Arc::clone(&workspace));
806 let project =
807 surface.roster().project_named("forge").expect("configured project").key.clone();
808 let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
809 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
812 workspace.seed_test_worker_row(&project, "reviewer");
813 workspace.record_spawn_failure_for_test(&slot, "the account was rate limited");
814
815 let agents = surface.agents();
816 let row = agents
817 .for_project(&project)
818 .iter()
819 .find(|row| row.slot == slot)
820 .expect("the failed worker has a row");
821 assert_eq!(
822 row.lifecycle,
823 SessionLifecycleState::Failed,
824 "which is the one the close dismisses",
825 );
826
827 workspace
828 .dispatch(forge_workspace::Command::CloseWorker {
829 project_key: project.clone(),
830 label: "reviewer".to_owned(),
831 })
832 .expect("a close on a failed worker is dispatched");
833
834 assert_eq!(workspace.spawn_failure(&slot), None, "the failure trace is dismissed");
835 let agents = surface.agents();
836 let row = agents
837 .for_project(&project)
838 .iter()
839 .find(|row| row.slot == slot)
840 .expect("the persisted row stays, so the label can be started again");
841 assert_eq!(
842 row.lifecycle,
843 SessionLifecycleState::Sleeping,
844 "and it reads asleep rather than failed",
845 );
846 }
847
848 #[test]
852 fn closing_a_failed_worker_with_a_tombstone_entry_keeps_its_row() {
853 let (workspace, _dir) = crate::surface::testing::workspace();
854 let surface = ViewSurface::new(Arc::clone(&workspace));
855 let project =
856 surface.roster().project_named("forge").expect("configured project").key.clone();
857 let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
858 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
860 workspace.seed_test_worker_row(&project, "reviewer");
861 workspace.insert_live_worker(&project, worker_row("reviewer", WorkerLiveness::Failed));
862
863 workspace
864 .dispatch(forge_workspace::Command::CloseWorker {
865 project_key: project.clone(),
866 label: "reviewer".to_owned(),
867 })
868 .expect("a close on a failed worker is dispatched");
869
870 assert!(workspace.list_live_workers(&project).is_empty(), "the tombstone entry goes");
871 let agents = surface.agents();
872 let row = agents
873 .for_project(&project)
874 .iter()
875 .find(|row| row.slot == slot)
876 .expect("the persisted row stays, so the label can be started again");
877 assert_eq!(
878 row.lifecycle,
879 SessionLifecycleState::Sleeping,
880 "and it reads asleep rather than failed",
881 );
882 }
883
884 #[test]
889 fn closing_a_crashed_worker_spends_its_death_and_keeps_its_row() {
890 let (workspace, _dir) = crate::surface::testing::workspace();
891 let surface = ViewSurface::new(Arc::clone(&workspace));
892 let project =
893 surface.roster().project_named("forge").expect("configured project").key.clone();
894 let slot = SessionSlot::worker("TestOrg", "forge", "reviewer");
895 workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
896 workspace.seed_test_worker_row(&project, "reviewer");
897 workspace.insert_live_worker(&project, worker_row("reviewer", WorkerLiveness::Failed));
898 workspace.record_spawn_failure_for_test(&slot, "the subprocess exited");
899 workspace.record_death_for_test(
900 &slot,
901 "dead-occupant",
902 "the claude subprocess closed its output stream",
903 );
904
905 let agents = surface.agents();
906 let row = agents
907 .for_project(&project)
908 .iter()
909 .find(|row| row.slot == slot)
910 .expect("the crashed worker has a row");
911 assert!(row.died, "the death is on the row before the close");
912 assert_eq!(row.lifecycle, SessionLifecycleState::Failed, "beside the failed lifecycle");
913
914 workspace
915 .dispatch(forge_workspace::Command::CloseWorker {
916 project_key: project.clone(),
917 label: "reviewer".to_owned(),
918 })
919 .expect("a close on a crashed worker is dispatched");
920
921 assert!(
922 !workspace.death_stands(&slot),
923 "the close is the reader acting on the row, so the death goes with it",
924 );
925 assert_eq!(workspace.spawn_failure(&slot), None, "and the failure record with it");
926 let agents = surface.agents();
927 let row = agents
928 .for_project(&project)
929 .iter()
930 .find(|row| row.slot == slot)
931 .expect("the persisted row stays, so the label can be started again");
932 assert!(!row.died, "the row no longer carries the death");
933 assert_eq!(
934 row.lifecycle,
935 SessionLifecycleState::Sleeping,
936 "and it reads asleep rather than failed",
937 );
938 }
939
940 #[test]
944 fn closing_a_crashed_lead_spends_its_death() {
945 let (workspace, _dir) = crate::surface::testing::workspace();
946 let surface = ViewSurface::new(Arc::clone(&workspace));
947 let project =
948 surface.roster().project_named("forge").expect("configured project").key.clone();
949 let lead = SessionSlot::lead("TestOrg", "forge");
950 workspace.record_spawn_failure_for_test(&lead, "the subprocess exited");
951 workspace.record_death_for_test(
952 &lead,
953 "dead-occupant",
954 "the claude subprocess closed its output stream",
955 );
956
957 let agents = surface.agents();
958 let row = agents.for_project(&project).first().expect("the crashed lead has a row");
959 assert!(row.died, "the death is on the row before the close");
960
961 workspace
962 .dispatch(forge_workspace::Command::CloseSession { session_key: lead.clone() })
963 .expect("a close on a crashed lead is dispatched");
964
965 assert!(!workspace.death_stands(&lead), "the close spends the death");
966 let agents = surface.agents();
967 assert!(
968 agents.for_project(&project).is_empty(),
969 "and the row goes: no session, no failure, no death",
970 );
971 }
972
973 #[test]
978 fn closing_a_lead_dismisses_its_stranded_workers_failure() {
979 let (workspace, _dir) = crate::surface::testing::workspace();
980 let surface = ViewSurface::new(Arc::clone(&workspace));
981 let project =
982 surface.roster().project_named("forge").expect("configured project").key.clone();
983 let lead = SessionSlot::lead("TestOrg", "forge");
984 let worker = SessionSlot::worker("TestOrg", "forge", "reviewer");
985 workspace.register_domain_session(lead.clone(), None);
986 workspace.seed_test_worker_row(&project, "reviewer");
987 workspace.record_spawn_failure_for_test(&worker, "the account was rate limited");
988
989 workspace
990 .dispatch(forge_workspace::Command::CloseSession { session_key: lead.clone() })
991 .expect("a close on the lead is dispatched");
992
993 assert_eq!(
994 workspace.spawn_failure(&worker),
995 None,
996 "the lead's close takes the project's failure set with it",
997 );
998 assert_eq!(
999 workspace.worker_labels_by_project().get(&project).cloned().unwrap_or_default(),
1000 vec!["reviewer".to_owned()],
1001 "and the worker's row stays, so the seat can be started again",
1002 );
1003 let agents = surface.agents();
1004 assert!(
1005 agents.for_project(&project).is_empty(),
1006 "a project with no lead behind it contributes no rows at all",
1007 );
1008 }
1009
1010 #[test]
1016 fn closing_a_lead_spends_its_crashed_workers_death() {
1017 let (workspace, _dir) = crate::surface::testing::workspace();
1018 let surface = ViewSurface::new(Arc::clone(&workspace));
1019 let project =
1020 surface.roster().project_named("forge").expect("configured project").key.clone();
1021 let lead = SessionSlot::lead("TestOrg", "forge");
1022 let worker = SessionSlot::worker("TestOrg", "forge", "reviewer");
1023 workspace.register_domain_session(lead.clone(), None);
1024 workspace.seed_test_worker_row(&project, "reviewer");
1025 workspace.record_spawn_failure_for_test(&worker, "the subprocess exited");
1026 workspace.record_death_for_test(
1027 &worker,
1028 "dead-occupant",
1029 "the claude subprocess closed its output stream",
1030 );
1031
1032 let agents = surface.agents();
1033 let row = agents
1034 .for_project(&project)
1035 .iter()
1036 .find(|row| row.slot == worker)
1037 .expect("the crashed worker has a row");
1038 assert!(row.died, "the death is on the row before the close");
1039
1040 workspace
1041 .dispatch(forge_workspace::Command::CloseSession { session_key: lead.clone() })
1042 .expect("a close on the lead is dispatched");
1043
1044 workspace.register_domain_session(lead, None);
1048
1049 assert!(!workspace.death_stands(&worker), "the lead's close spent the worker's death");
1050 let agents = surface.agents();
1051 let row = agents
1052 .for_project(&project)
1053 .iter()
1054 .find(|row| row.slot == worker)
1055 .expect("the persisted worker row draws again under the restarted lead");
1056 assert!(!row.died, "the row no longer draws a death");
1057 assert_eq!(
1058 row.lifecycle,
1059 SessionLifecycleState::Sleeping,
1060 "and it reads asleep rather than failed",
1061 );
1062 }
1063
1064 fn worker_row(label: &str, status: WorkerLiveness) -> forge_workspace::WorkerEntry {
1065 forge_workspace::WorkerEntry {
1066 label: label.to_owned(),
1067 charter: format!("charter for {label}"),
1068 slot: SessionSlot::worker("TestOrg", "forge", label),
1069 session_id: None,
1070 status,
1071 spawned_at: SystemTime::UNIX_EPOCH,
1072 spawned_by: SessionSlot::lead("TestOrg", "forge"),
1073 needs_tag: false,
1074 is_git_repo_at_spawn: false,
1075 cwd: None,
1076 diagnostic: None,
1077 kick: None,
1078 }
1079 }
1080}