@team-agent/installer 0.3.20 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/runtime_detectors.rs +251 -2
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  11. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  13. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  14. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  16. package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
  17. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  18. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  19. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  20. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  21. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  22. package/crates/team-agent/src/messaging/send.rs +10 -0
  23. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  24. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  25. package/crates/team-agent/src/session_capture.rs +261 -1
  26. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  27. package/crates/team-agent/src/tmux_backend.rs +138 -2
  28. package/crates/team-agent/src/transport/test_support.rs +25 -0
  29. package/crates/team-agent/src/transport.rs +11 -0
  30. package/package.json +4 -4
@@ -23,8 +23,7 @@ pub fn restart_with_session_convergence_deadline(
23
23
  session_converge_deadline_ms: Option<u64>,
24
24
  ) -> Result<RestartReport, LifecycleError> {
25
25
  let paths = lifecycle_paths(workspace, team)?;
26
- let transport =
27
- lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
26
+ let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
28
27
  restart_with_transport_with_session_convergence_deadline(
29
28
  workspace,
30
29
  allow_fresh,
@@ -148,12 +147,10 @@ pub fn restart_with_transport_with_session_convergence_deadline(
148
147
  allow_fresh,
149
148
  )?;
150
149
  if convergence.converged && convergence.changed {
151
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
152
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
150
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
153
151
  }
154
152
  if repair_resume_sessions_from_event_log(&selected.run_workspace, &mut state)? {
155
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
156
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
153
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
157
154
  let missing_after_repair = restart_required_missing_session_agent_ids(&state);
158
155
  convergence.changed = true;
159
156
  convergence.converged = missing_after_repair.is_empty();
@@ -173,8 +170,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
173
170
  });
174
171
  }
175
172
  if !convergence.converged && convergence.changed {
176
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
177
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
173
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
178
174
  }
179
175
  let mut forced_fresh_missing = if convergence.converged {
180
176
  std::collections::BTreeSet::new()
@@ -222,8 +218,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
222
218
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
223
219
  mark_leader_receiver_rebind_required(&mut state, &session_name);
224
220
  mark_restart_targets_stopped_after_teardown(&mut state, &plan.decisions);
225
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
226
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
221
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
227
222
  }
228
223
  let mut successful_agents: Vec<RestartedAgent> = Vec::new();
229
224
  let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
@@ -353,8 +348,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
353
348
  }
354
349
  }
355
350
  // END_B5_RESTART_ISOLATION_LOOP
356
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
357
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
351
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
358
352
  if fatal_resume_failure {
359
353
  let attach_commands = Vec::new();
360
354
  let next_actions = restart_failure_next_actions(&failed_agents);
@@ -868,6 +862,14 @@ fn mark_restart_targets_stopped_after_teardown(
868
862
  }
869
863
  }
870
864
 
865
+ fn save_restart_state(
866
+ workspace: &Path,
867
+ state: &mut serde_json::Value,
868
+ team_key: &str,
869
+ ) -> Result<(), LifecycleError> {
870
+ save_restart_projected_state(workspace, state, team_key)
871
+ }
872
+
871
873
  fn mark_agent_respawned(
872
874
  state: &mut serde_json::Value,
873
875
  agent_id: &AgentId,
@@ -906,7 +908,13 @@ fn mark_agent_respawned(
906
908
  });
907
909
  if let Some(pane_pid) = pane_pid {
908
910
  agent.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
911
+ } else {
912
+ agent.remove("pane_pid");
909
913
  }
914
+ agent.insert(
915
+ "spawned_at".to_string(),
916
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
917
+ );
910
918
  if matches!(
911
919
  restart_mode,
912
920
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
@@ -929,7 +937,10 @@ fn mark_agent_respawned(
929
937
  "layout_index".to_string(),
930
938
  serde_json::json!(placement.layout_index),
931
939
  );
932
- agent.insert("pane_index".to_string(), serde_json::json!(placement.pane_index));
940
+ agent.insert(
941
+ "pane_index".to_string(),
942
+ serde_json::json!(placement.pane_index),
943
+ );
933
944
  agent.insert(
934
945
  "display".to_string(),
935
946
  serde_json::json!({
@@ -963,6 +974,8 @@ fn mark_agent_respawned(
963
974
  }
964
975
  agent.remove("startup_prompts");
965
976
  agent.remove("startup_prompt_status");
977
+ agent.remove("startup_prompt_probe_epoch");
978
+ agent.remove("startup_prompt_probe_disabled_at");
966
979
  Ok(())
967
980
  }
968
981
 
@@ -1403,3 +1416,272 @@ fn restart_candidate_has_context(state: &serde_json::Value) -> bool {
1403
1416
  })
1404
1417
  })
1405
1418
  }
1419
+
1420
+ #[cfg(test)]
1421
+ mod tests {
1422
+ use super::*;
1423
+ use std::collections::BTreeMap;
1424
+ use std::path::Path;
1425
+
1426
+ use crate::transport::{
1427
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport, Key,
1428
+ PaneField, PaneInfo, PaneLiveness, SetEnvOutcome, SpawnResult, Target, Transport,
1429
+ TransportError,
1430
+ };
1431
+
1432
+ struct RespawnEpochTransport;
1433
+
1434
+ impl Transport for RespawnEpochTransport {
1435
+ fn kind(&self) -> BackendKind {
1436
+ BackendKind::Tmux
1437
+ }
1438
+
1439
+ fn spawn_first(
1440
+ &self,
1441
+ _session: &SessionName,
1442
+ _window: &WindowName,
1443
+ _argv: &[String],
1444
+ _cwd: &Path,
1445
+ _env: &BTreeMap<String, String>,
1446
+ ) -> Result<SpawnResult, TransportError> {
1447
+ unimplemented!("not used by respawn epoch test")
1448
+ }
1449
+
1450
+ fn spawn_into(
1451
+ &self,
1452
+ _session: &SessionName,
1453
+ _window: &WindowName,
1454
+ _argv: &[String],
1455
+ _cwd: &Path,
1456
+ _env: &BTreeMap<String, String>,
1457
+ ) -> Result<SpawnResult, TransportError> {
1458
+ unimplemented!("not used by respawn epoch test")
1459
+ }
1460
+
1461
+ fn inject(
1462
+ &self,
1463
+ _target: &Target,
1464
+ _payload: &InjectPayload,
1465
+ _submit: Key,
1466
+ _bracketed: bool,
1467
+ ) -> Result<InjectReport, TransportError> {
1468
+ unimplemented!("not used by respawn epoch test")
1469
+ }
1470
+
1471
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
1472
+ unimplemented!("not used by respawn epoch test")
1473
+ }
1474
+
1475
+ fn capture(
1476
+ &self,
1477
+ _target: &Target,
1478
+ range: CaptureRange,
1479
+ ) -> Result<CapturedText, TransportError> {
1480
+ Ok(CapturedText {
1481
+ text: String::new(),
1482
+ range,
1483
+ })
1484
+ }
1485
+
1486
+ fn query(
1487
+ &self,
1488
+ _target: &Target,
1489
+ _field: PaneField,
1490
+ ) -> Result<Option<String>, TransportError> {
1491
+ Ok(None)
1492
+ }
1493
+
1494
+ fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
1495
+ Ok(PaneLiveness::Live)
1496
+ }
1497
+
1498
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
1499
+ Ok(Vec::new())
1500
+ }
1501
+
1502
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
1503
+ Ok(true)
1504
+ }
1505
+
1506
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
1507
+ Ok(Vec::new())
1508
+ }
1509
+
1510
+ fn set_session_env(
1511
+ &self,
1512
+ _session: &SessionName,
1513
+ _key: &str,
1514
+ _value: &str,
1515
+ ) -> Result<SetEnvOutcome, TransportError> {
1516
+ Ok(SetEnvOutcome::Applied)
1517
+ }
1518
+
1519
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
1520
+ Ok(())
1521
+ }
1522
+
1523
+ fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
1524
+ Ok(())
1525
+ }
1526
+
1527
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
1528
+ Ok(AttachOutcome::Attached)
1529
+ }
1530
+ }
1531
+
1532
+ fn disabled_safety() -> DangerousApproval {
1533
+ DangerousApproval {
1534
+ enabled: false,
1535
+ source: DangerousApprovalSource::Disabled,
1536
+ inherited: false,
1537
+ provider: None,
1538
+ flag: None,
1539
+ worker_capability_above_leader: false,
1540
+ ancestry_binary_name: None,
1541
+ unexpected_binary: false,
1542
+ }
1543
+ }
1544
+
1545
+ #[test]
1546
+ fn respawn_refreshes_startup_probe_epoch_inputs() {
1547
+ let mut state = serde_json::json!({
1548
+ "agents": {
1549
+ "w1": {
1550
+ "status": "running",
1551
+ "window": "w1-old",
1552
+ "pane_id": "%old",
1553
+ "pane_pid": 1010,
1554
+ "spawned_at": "2026-06-01T00:00:00+00:00",
1555
+ "startup_prompts": "disabled_for_epoch",
1556
+ "startup_prompt_status": "disabled_for_epoch",
1557
+ "startup_prompt_probe_epoch": "pane_pid:1010",
1558
+ "startup_prompt_probe_disabled_at": "2026-06-01T00:02:30+00:00"
1559
+ }
1560
+ }
1561
+ });
1562
+ let spawn = SpawnedAgentWindow {
1563
+ spawn: SpawnResult {
1564
+ pane_id: PaneId::new("%new"),
1565
+ session: SessionName::new("team-epoch"),
1566
+ window: WindowName::new("w1"),
1567
+ child_pid: Some(2020),
1568
+ },
1569
+ plan: crate::provider::CommandPlan::argv_only(vec!["codex".to_string()]),
1570
+ profile_launch: crate::provider::ProviderProfileLaunch::default(),
1571
+ layout_placement: None,
1572
+ };
1573
+ let before = chrono::Utc::now();
1574
+
1575
+ mark_agent_respawned(
1576
+ &mut state,
1577
+ &AgentId::new("w1"),
1578
+ StartMode::Resumed,
1579
+ &spawn,
1580
+ &RespawnEpochTransport,
1581
+ &disabled_safety(),
1582
+ )
1583
+ .expect("mark respawned");
1584
+
1585
+ let agent = state.pointer("/agents/w1").expect("agent");
1586
+ assert_eq!(
1587
+ agent.get("pane_id").and_then(serde_json::Value::as_str),
1588
+ Some("%new")
1589
+ );
1590
+ assert_eq!(
1591
+ agent.get("pane_pid").and_then(serde_json::Value::as_u64),
1592
+ Some(2020)
1593
+ );
1594
+ let spawned_at = agent
1595
+ .get("spawned_at")
1596
+ .and_then(serde_json::Value::as_str)
1597
+ .expect("spawned_at refreshed");
1598
+ let spawned_at = chrono::DateTime::parse_from_rfc3339(spawned_at)
1599
+ .expect("spawned_at rfc3339")
1600
+ .with_timezone(&chrono::Utc);
1601
+ assert!(
1602
+ spawned_at >= before - chrono::Duration::seconds(1),
1603
+ "respawn must start a fresh startup-probe grace window; spawned_at={spawned_at}, before={before}"
1604
+ );
1605
+ for key in [
1606
+ "startup_prompts",
1607
+ "startup_prompt_status",
1608
+ "startup_prompt_probe_epoch",
1609
+ "startup_prompt_probe_disabled_at",
1610
+ ] {
1611
+ assert!(
1612
+ agent.get(key).is_none(),
1613
+ "respawned pane must not inherit old startup prompt epoch field {key}"
1614
+ );
1615
+ }
1616
+ }
1617
+
1618
+ #[test]
1619
+ fn restart_projection_sync_updates_active_team_and_current_alias() {
1620
+ let mut state = serde_json::json!({
1621
+ "active_team_key": "team-alpha",
1622
+ "team_dir": "/tmp/team-alpha",
1623
+ "session_name": "team-alpha",
1624
+ "agents": {
1625
+ "alpha": {
1626
+ "status": "running",
1627
+ "pane_id": "%new",
1628
+ "pane_pid": 47650,
1629
+ "spawned_at": "2026-06-13T04:00:00+00:00"
1630
+ }
1631
+ },
1632
+ "teams": {
1633
+ "current": {
1634
+ "team_dir": "/tmp/team-alpha",
1635
+ "session_name": "team-alpha",
1636
+ "agents": {
1637
+ "alpha": {
1638
+ "status": "running",
1639
+ "pane_id": "%new",
1640
+ "pane_pid": 47650,
1641
+ "spawned_at": "2026-06-13T04:00:00+00:00"
1642
+ }
1643
+ }
1644
+ },
1645
+ "team-alpha": {
1646
+ "team_dir": "/tmp/team-alpha",
1647
+ "session_name": "team-alpha",
1648
+ "agents": {
1649
+ "alpha": {
1650
+ "status": "running",
1651
+ "pane_id": "%old",
1652
+ "pane_pid": 46784,
1653
+ "spawned_at": "2026-06-13T03:00:00+00:00",
1654
+ "startup_prompt_probe_epoch": "pane_pid:46784"
1655
+ }
1656
+ }
1657
+ }
1658
+ }
1659
+ });
1660
+
1661
+ sync_restart_team_projections(&mut state, "team-alpha");
1662
+
1663
+ for pointer in [
1664
+ "/agents/alpha",
1665
+ "/teams/current/agents/alpha",
1666
+ "/teams/team-alpha/agents/alpha",
1667
+ ] {
1668
+ let agent = state.pointer(pointer).expect(pointer);
1669
+ assert_eq!(
1670
+ agent.get("pane_id").and_then(serde_json::Value::as_str),
1671
+ Some("%new")
1672
+ );
1673
+ assert_eq!(
1674
+ agent.get("pane_pid").and_then(serde_json::Value::as_u64),
1675
+ Some(47650)
1676
+ );
1677
+ assert_eq!(
1678
+ agent.get("spawned_at").and_then(serde_json::Value::as_str),
1679
+ Some("2026-06-13T04:00:00+00:00")
1680
+ );
1681
+ assert!(
1682
+ agent.get("startup_prompt_probe_epoch").is_none(),
1683
+ "{pointer} must not retain the old startup probe epoch"
1684
+ );
1685
+ }
1686
+ }
1687
+ }
@@ -1118,6 +1118,20 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1118
1118
  vec!["spawn_first", "spawn_split", "spawn_split", "spawn_into"],
1119
1119
  "4 default-adaptive workers should create team-w1 with 3 panes, then team-w2"
1120
1120
  );
1121
+ assert_eq!(
1122
+ transport
1123
+ .pane_title_records()
1124
+ .iter()
1125
+ .map(|(_, window, pane, title)| (window.as_str(), pane.as_str(), title.as_str()))
1126
+ .collect::<Vec<_>>(),
1127
+ vec![
1128
+ ("team-w1", "%0", "w1"),
1129
+ ("team-w1", "%1", "w2"),
1130
+ ("team-w1", "%2", "w3"),
1131
+ ("team-w2", "%3", "w4"),
1132
+ ],
1133
+ "adaptive workers must set tmux pane titles to the agent id"
1134
+ );
1121
1135
  assert_eq!(
1122
1136
  launch
1123
1137
  .started
@@ -1147,6 +1161,14 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1147
1161
  );
1148
1162
 
1149
1163
  let (_raw, state) = raw_runtime_state(workspace);
1164
+ assert!(
1165
+ transport
1166
+ .pane_title_records()
1167
+ .iter()
1168
+ .all(|(session, _, _, _)| Some(session.as_str())
1169
+ == state.get("session_name").and_then(serde_json::Value::as_str)),
1170
+ "pane titles must be configured inside the team session"
1171
+ );
1150
1172
  assert_eq!(state["display_backend"], json!("adaptive"));
1151
1173
  assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
1152
1174
  assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
@@ -1155,6 +1177,14 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1155
1177
  state.pointer("/agents/w1/display/backend"),
1156
1178
  Some(&json!("adaptive"))
1157
1179
  );
1180
+ assert_eq!(
1181
+ state.pointer("/agents/w1/display/pane_title"),
1182
+ Some(&json!("w1"))
1183
+ );
1184
+ assert_eq!(
1185
+ state.pointer("/agents/w1/display/linked_session"),
1186
+ Some(&json!(null))
1187
+ );
1158
1188
  assert_eq!(
1159
1189
  state.pointer("/agents/w4/layout_window"),
1160
1190
  Some(&json!("team-w2"))
@@ -41,11 +41,11 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
41
41
  }
42
42
  fn spawn_first(&self, session: &crate::transport::SessionName, window: &crate::transport::WindowName, argv: &[String], _cwd: &std::path::Path, _env: &std::collections::BTreeMap<String, String>) -> Result<crate::transport::SpawnResult, crate::transport::TransportError> {
43
43
  self.spawns.lock().unwrap().push(("spawn_first".to_string(), argv.to_vec()));
44
- Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new("%0"), session: session.clone(), window: window.clone(), child_pid: None })
44
+ Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new("%0"), session: session.clone(), window: window.clone(), child_pid: Some(6856) })
45
45
  }
46
46
  fn spawn_into(&self, session: &crate::transport::SessionName, window: &crate::transport::WindowName, argv: &[String], _cwd: &std::path::Path, _env: &std::collections::BTreeMap<String, String>) -> Result<crate::transport::SpawnResult, crate::transport::TransportError> {
47
47
  self.spawns.lock().unwrap().push(("spawn_into".to_string(), argv.to_vec()));
48
- Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new(format!("%{}", window.as_str())), session: session.clone(), window: window.clone(), child_pid: None })
48
+ Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new(format!("%{}", window.as_str())), session: session.clone(), window: window.clone(), child_pid: Some(6856) })
49
49
  }
50
50
  fn inject(&self, _t: &crate::transport::Target, _p: &crate::transport::InjectPayload, _s: crate::transport::Key, _b: bool) -> Result<crate::transport::InjectReport, crate::transport::TransportError> {
51
51
  unimplemented!("not reached by start_agent respawn")
@@ -54,16 +54,19 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
54
54
  unimplemented!("not reached")
55
55
  }
56
56
  fn capture(&self, _t: &crate::transport::Target, _r: crate::transport::CaptureRange) -> Result<crate::transport::CapturedText, crate::transport::TransportError> {
57
- unimplemented!("not reached")
57
+ Ok(crate::transport::CapturedText {
58
+ text: String::new(),
59
+ range: crate::transport::CaptureRange::Full,
60
+ })
58
61
  }
59
62
  fn query(&self, _t: &crate::transport::Target, _f: crate::transport::PaneField) -> Result<Option<String>, crate::transport::TransportError> {
60
63
  unimplemented!("not reached")
61
64
  }
62
65
  fn liveness(&self, _p: &crate::transport::PaneId) -> Result<crate::model::enums::PaneLiveness, crate::transport::TransportError> {
63
- unimplemented!("not reached")
66
+ Ok(crate::model::enums::PaneLiveness::Live)
64
67
  }
65
68
  fn list_targets(&self) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
66
- unimplemented!("not reached")
69
+ Ok(Vec::new())
67
70
  }
68
71
  fn has_session(&self, _s: &crate::transport::SessionName) -> Result<bool, crate::transport::TransportError> {
69
72
  Ok(self.session_exists)
@@ -163,6 +166,87 @@ fn reset_agent_discard_session_rebuilds_window_via_start_respawn() {
163
166
  "into a LIVE session (has_session=true) the window rebuild is a new-window (spawn_into); got {recorded:?}"
164
167
  );
165
168
  }
169
+
170
+ #[test]
171
+ fn reset_agent_discard_session_syncs_projection_epoch_inputs_for_restart_agent_command() {
172
+ let ws = restart_ws_two_resumable_workers();
173
+ let mut state = crate::state::persist::load_runtime_state(&ws).expect("load state");
174
+ state["team_dir"] = json!(ws.to_string_lossy());
175
+ state["active_team_key"] = json!("restartteam");
176
+ state["agents"]["alpha"]["window"] = json!("alpha");
177
+ state["agents"]["alpha"]["pane_id"] = json!("%old");
178
+ state["agents"]["alpha"]["pane_pid"] = json!(6304);
179
+ state["agents"]["alpha"]["spawned_at"] = json!("2026-06-13T01:00:00+00:00");
180
+ state["agents"]["alpha"]["startup_prompts"] = json!("disabled_for_epoch");
181
+ state["agents"]["alpha"]["startup_prompt_status"] = json!("disabled_for_epoch");
182
+ state["agents"]["alpha"]["startup_prompt_probe_epoch"] = json!("pane_pid:6304");
183
+ state["agents"]["alpha"]["startup_prompt_probe_disabled_at"] =
184
+ json!("2026-06-13T01:01:00+00:00");
185
+ let compact = crate::state::projection::compact_team_state(&state);
186
+ state["teams"] = json!({
187
+ "current": compact.clone(),
188
+ "restartteam": compact,
189
+ });
190
+ crate::state::persist::save_runtime_state(&ws, &state).expect("save stale projections");
191
+
192
+ let before = chrono::Utc::now();
193
+ let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
194
+ let transport = SessionProbeRecordingTransport {
195
+ spawns: std::sync::Arc::clone(&spawns),
196
+ session_exists: true,
197
+ };
198
+ let outcome = reset_agent_with_transport(&ws, &AgentId::new("alpha"), true, false, None, &transport)
199
+ .expect("reset-agent alpha --discard-session --no-display");
200
+ assert!(
201
+ matches!(outcome, ResetAgentOutcome::Reset { .. }),
202
+ "restart-agent alpha --discard-session --no-display must complete reset; got {outcome:?}"
203
+ );
204
+
205
+ let state = crate::state::persist::load_runtime_state(&ws).expect("reload state");
206
+ for pointer in [
207
+ "/agents/alpha",
208
+ "/teams/current/agents/alpha",
209
+ "/teams/restartteam/agents/alpha",
210
+ ] {
211
+ let agent = state.pointer(pointer).expect(pointer);
212
+ assert_eq!(
213
+ agent.get("pane_id").and_then(serde_json::Value::as_str),
214
+ Some("%alpha"),
215
+ "{pointer} must project the reset-spawned pane, not the stale pre-reset pane"
216
+ );
217
+ assert_eq!(
218
+ agent.get("pane_pid").and_then(serde_json::Value::as_u64),
219
+ Some(6856),
220
+ "{pointer} must project the reset-spawned pane pid, not stale pid 6304"
221
+ );
222
+ let spawned_at = agent
223
+ .get("spawned_at")
224
+ .and_then(serde_json::Value::as_str)
225
+ .expect("spawned_at refreshed");
226
+ assert_ne!(
227
+ spawned_at, "2026-06-13T01:00:00+00:00",
228
+ "{pointer} must not retain stale spawned_at"
229
+ );
230
+ let spawned_at = chrono::DateTime::parse_from_rfc3339(spawned_at)
231
+ .expect("spawned_at rfc3339")
232
+ .with_timezone(&chrono::Utc);
233
+ assert!(
234
+ spawned_at >= before - chrono::Duration::seconds(1),
235
+ "{pointer} must get a fresh startup-probe grace epoch; spawned_at={spawned_at}, before={before}"
236
+ );
237
+ for key in [
238
+ "startup_prompts",
239
+ "startup_prompt_status",
240
+ "startup_prompt_probe_epoch",
241
+ "startup_prompt_probe_disabled_at",
242
+ ] {
243
+ assert!(
244
+ agent.get(key).is_none(),
245
+ "{pointer} must not retain old startup probe epoch field {key}"
246
+ );
247
+ }
248
+ }
249
+ }
166
250
  // item ② — remove invoked with a NON-canonical input (the workspace's own `.team` subpath) must resolve
167
251
  // the CANONICAL live run-workspace and apply the removal to the LIVE roster — not the subpath's detached
168
252
  // runtime. canonical_run_workspace($ws/.team) == $ws. The regression made remove "succeed" against a