Skip to main content

forge_server/surface/
agents.rs

1//! `agents()`: every project's lead and workers as one row type.
2
3use 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/// One agent as a view reads it. A lead and a worker are the same row:
13/// which project spawned it, and what nests under what, are the view's
14/// business rather than the row's.
15#[derive(Clone, serde::Serialize, serde::Deserialize)]
16pub struct AgentRow {
17    pub slot: SessionSlot,
18    /// The worker's own label, or `"lead"` for a project's own agent.
19    pub label: String,
20    pub lifecycle: SessionLifecycleState,
21    /// Which heal attempt is next while the seat is `Restarting`; `None`
22    /// for every other lifecycle.
23    pub heal_attempt: Option<u32>,
24    /// How many restarts a heal may make, so the row can say "1 of 3"
25    /// without the client holding a policy of its own.
26    pub heal_attempts: Option<u32>,
27    pub has_background_work: bool,
28    pub pending: Option<PendingKind>,
29    /// How many prompts the session is holding. Zero when it holds none,
30    /// and more than one when a prompt waits behind another.
31    pub pending_depth: usize,
32    /// How many asks have ever arrived for the seat. **The ask's identity**:
33    /// a client announces a new one by its number, so an ask replacing
34    /// another at the same depth is news and an answer that shrinks the
35    /// batch is not.
36    pub pending_seq: u64,
37    pub last_activity: Option<SystemTime>,
38    /// Why this session failed, from the core's own record: a worker's
39    /// spawn diagnostic, or the failure the slot's last connection left
40    /// behind. `None` for a session that has not failed.
41    pub reason: Option<String>,
42    /// When this session's newest turn ended in failure, which the rail
43    /// marks until the reader opens the seat. `None` when nothing failed,
44    /// when the reader cancelled that turn, or when a newer turn has since
45    /// been committed.
46    pub failed_turn: Option<SystemTime>,
47    /// Whether a session on this seat ended and no reader has been shown it
48    /// since. The row's failure is the durable trace of a crash nobody
49    /// watched, and this is what tells a seat that never came up (which
50    /// stays parked with its reason) from one that should be started again.
51    pub died: bool,
52    /// The seat's own tree, home-shortened, when the worker runs somewhere
53    /// other than its project's directory. `None` for a lead, for a worker
54    /// in its project's tree, and for one this cannot place - so a row
55    /// drawn with one can name the tree beside its project's siblings.
56    pub tree: Option<String>,
57}
58
59/// `path` with the home directory written as `~`, the way a project's
60/// `display_path` is authored: the seat's tree should read as the same
61/// kind of name its project's rows do, not as a machine path.
62fn 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
71/// What a session is waiting on a person for. The core's own kind rather
72/// than a second enum of the same two arms, so the row and the read cannot
73/// drift apart.
74pub use forge_workspace::PendingInteractionKind as PendingKind;
75
76/// Every project's agents, in the order `list_projects` returned the
77/// projects.
78pub struct Agents {
79    rows: Vec<AgentRow>,
80    /// Where each project's rows sit in `rows`, so `for_project` answers
81    /// with a slice rather than a walk of the fleet.
82    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            // A project with no session behind it is the home's Start row
95            // rather than an agent, so it contributes no rows at all - but a
96            // row is still owed while a failure stands, and while a death no
97            // reader has been shown stands. **The death is its own gate**:
98            // the recovering connect clears the failure by design, so a seat
99            // revived under a standing death would otherwise lose its row
100            // between the revival and the next show, and the show after that
101            // would draw the old death on a session that never died.
102            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    /// The rows for `key`, lead first, empty for a project that
130    /// contributed none.
131    pub fn for_project(&self, key: &ProjectKey) -> &[AgentRow] {
132        // `rows` is contiguous per project because `collect` builds it one
133        // project at a time, so the span is the whole answer.
134        self.spans.get(key).map_or(&[], |span| &self.rows[span.clone()])
135    }
136
137    /// Every row, across every project.
138    pub fn all(&self) -> &[AgentRow] {
139        &self.rows
140    }
141}
142
143/// One row, with the two liveness states the slot derivation cannot see:
144/// a worker that has not connected yet, and one whose spawn failed.
145fn 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        // A worker's own spawn diagnostic is the more specific record, so
170        // it wins; a lead has only the slot's, which is why the fallback
171        // is here rather than at the call sites. **A standing death is the
172        // last of the three**: the recovering connect clears the failure
173        // (the lifecycle has to tell the truth about now), and the reason
174        // the seat died still has to reach the row that carries `died`.
175        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    /// Every project's agents: its lead, then its workers.
187    pub fn agents(&self) -> Agents {
188        Agents::collect(&self.workspace)
189    }
190
191    /// When `slot`'s newest turn ended in failure, or `None` when nothing
192    /// failed or a newer turn has since been committed.
193    ///
194    /// The same fact a row carries, read for one seat: showing the seat
195    /// spends that mark, and whether there was one to spend decides whether
196    /// the home needs re-encoding for the connection that attached.
197    pub fn failed_turn(&self, slot: &SessionSlot) -> Option<SystemTime> {
198        self.workspace.session_failed_turn(slot)
199    }
200
201    /// A client has `slot` on screen, which is this view saying the seat was
202    /// shown: a death the reader had not been shown is spent with it - the
203    /// mark and its reason leave the row - so a crash nobody watched is the
204    /// durable trace until someone looks. Answers whether anything was
205    /// spent, which is the connection's cue to re-encode the home it holds.
206    ///
207    /// **Called when the connection's showing and its subscription meet**,
208    /// from whichever half lands second. Both clients subscribe first on a
209    /// resume, but the route effect that sends `Showing` races the page's
210    /// mount, so a showing alone is remembered rather than spent until the
211    /// seat is actually watched.
212    ///
213    /// Called after the seat's own snapshot was encoded, deliberately: the
214    /// read that spends the mark is the read that shows it.
215    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    /// **Every kind's wire spelling is pinned.** A typo in a serde rename
231    /// compiles and ships a value no client narrows: the page falls back to
232    /// `permission` words and the shell drops that seat's row, so the roster
233    /// row's `pending` is drawn wrongly or not at all.
234    #[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    /// The row set the home draws: a project's lead first, then its
255    /// workers in the persisted-label order. Catches a collect that heads
256    /// the list with the workers, that reads labels from the live registry
257    /// rather than the persisted rows, or that drops the lead row because
258    /// no `WorkerEntry` describes it.
259    #[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    /// A project nobody has started is drawn by the home as a dormant row
283    /// with a Start rather than as an agent, so it contributes no rows
284    /// here. Catches a collect that emits a lead row for a slot with no
285    /// session behind it.
286    #[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    /// A worker row carries the worker's own live state rather than the
305    /// slot derivation's: a spawn that has not connected reads `Spawning`
306    /// and a dead one reads `Failed`, where `session_activity` alone would
307    /// call both `Idle` or `Sleeping` and hide them behind a calm mark.
308    #[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    /// A seat between heal attempts draws `Restarting` with its attempt and
345    /// cap, and a lead's row - which has no worker entry - carries the same
346    /// state from the workspace. Catches a row builder that maps the liveness
347    /// but drops the fields, and a lead path that reads the slot derivation
348    /// without the heal state.
349    #[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    /// A worker label with no live registry entry still gets a row, and
385    /// the row reads asleep: the despawned worker's row is shown until it
386    /// is despawned, so it must not be silently absent.
387    #[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    /// The ask reaches the row, which is what lets a needs-you row name
411    /// what it is waiting on instead of only marking it. Catches a row
412    /// wired to `None`, and one answering another session's pending set.
413    #[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    /// A failed row says why. The reason is the core's own record of the
446    /// failure, carried through the projection a render path reads rather
447    /// than re-fetched by the view. Catches a row that drops it, and one
448    /// that answers a live worker with another session's reason.
449    #[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    /// The failed-turn fact reaches the row, which is what the rail's
484    /// failure mark draws from. Catches a row that drops it, and one that
485    /// stamps a seat whose newest turn never failed.
486    #[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    /// The row names the tree a worker runs in when a lead pointed it at
513    /// one of its own, and names nothing for a worker in its project's own
514    /// directory - where the project's name already says it.
515    #[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        // A project with no session behind it contributes no rows at all,
522        // so the lead has to be up for the workers' rows to be drawn.
523        workspace.register_domain_session(SessionSlot::lead("TestOrg", "forge"), None);
524        // The worker's own tree, with the worktree a spawn would have
525        // forked there - the row's `where` names the tree, and the row is
526        // only offered when that tree's worktree is there.
527        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    /// The home directory is written as `~` the way a project's
578    /// `display_path` is authored, and a path that does not start under it
579    /// is left alone.
580    #[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    /// A session whose subprocess died keeps its row after the failure
588    /// released the session: this is what the fleet's left panel draws the
589    /// failed mark from when an agent dies. Catches a collect that drops the
590    /// row because no domain session is left behind - the shape the fleet
591    /// showed nothing for.
592    #[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    /// **A recovered seat reads the truth about now, with the death riding
622    /// as its own trace.** The recovering connect clears the failure - the
623    /// lifecycle is about the seat as it stands, and a live, working
624    /// project drawn failed is the same lie the issue was filed against,
625    /// mirrored - so what carries the death until a reader is shown the
626    /// seat is `died` plus the reason it left. Showing the seat takes both.
627    #[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        // Shown: the trace goes, the row stays.
657        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    /// And a seat that crashed and is still DOWN reads Failed with its
665    /// reason: that is the truth about a seat nothing brought back. Catches
666    /// a row that reports a live state for a seat no session is behind.
667    #[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    /// **A death keeps the row on its own.** With no session behind it and
699    /// no failure standing - the recovering connect clears the failure by
700    /// design, and a release takes the domain - the row is owed for the
701    /// death itself; skipped, the mark would vanish between the revival and
702    /// the next show and come back stale on a session that never died.
703    #[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        // No domain session (the release took it) and no failure (the
711        // recovering connect cleared it): the death alone.
712        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    /// A lead's failed spawn reaches its row, which is what the slot's own
738    /// failure record is for: the failure releases the session, so a row
739    /// reading only the lifecycle would call the slot dormant.
740    #[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    /// **Closing a failed seat dismisses its failure.** The failure record is
762    /// what parks the row in the needs-you group, and the close is the reader
763    /// saying they have seen it: the trace goes with the close so the seat
764    /// starts clean next time. Catches a close that releases the session and
765    /// leaves the failure holding the row red.
766    #[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    /// A worker whose spawn failed before an entry outlived it is still on the
799    /// roster through its persisted row, and the close has only a failure
800    /// trace to dismiss. The row stays - it is what lets the label start
801    /// again - and reads asleep rather than failed.
802    #[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        // A project with no lead behind it contributes no rows at all, so the
810        // project keeps a lead here for its worker row to be on the roster.
811        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    /// A failed worker whose entry survived as a tombstone draws its failure
849    /// from the entry rather than the slot record, so the close has to take
850    /// the entry too. The persisted row stays for the same reason.
851    #[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        // The project needs a lead behind it for its worker rows to be drawn.
859        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    /// **A worker that ran and then died carries BOTH traces**, and the close
885    /// is the reader acting on the row: the death is spent with the failure,
886    /// or the row keeps drawing `session ended` and stays in needs-you with
887    /// nothing left to dismiss it.
888    #[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    /// The lead's close takes the same two traces: a lead that connected and
941    /// then died is a crash the reader answered, so its row goes rather than
942    /// staying red with nothing left to dismiss it.
943    #[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    /// **A lead's close takes its whole project's failure set**, including a
974    /// stranded worker's record: one whose live entry was already gone when
975    /// its failure was recorded has nothing else to clear it, and the next
976    /// lead start would draw that worker failed.
977    #[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    /// **A lead's close takes a crashed worker's death too.** The failure
1011    /// record alone is not the whole trace: a worker that ran and died draws
1012    /// from `death_stands`, so the next lead would bring back a red row that
1013    /// no close could dismiss. Closing the lead and starting it again is the
1014    /// state a reader meets after the close.
1015    #[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        // The next lead is the state a reader meets after the close: the
1045        // project's rows draw again, and the worker's must not bring its
1046        // tombstone back.
1047        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}