@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
@@ -6,8 +6,8 @@
6
6
 
7
7
  use crate::cli::lifecycle_port::{sessions_to_kill, KillDecision};
8
8
  use crate::transport::{
9
- AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
10
- Key, PaneField, PaneId, PaneInfo, SessionName, SetEnvOutcome, SpawnResult, Target, Transport,
9
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport, Key,
10
+ PaneField, PaneId, PaneInfo, SessionName, SetEnvOutcome, SpawnResult, Target, Transport,
11
11
  TransportError, WindowName,
12
12
  };
13
13
  use serde_json::json;
@@ -33,7 +33,10 @@ fn rc4_exclusive_socket_kills_server() {
33
33
  // 空 session 集 → 逐 kill(no-op),不整 server 拆(没东西可拆)。
34
34
  assert_eq!(
35
35
  sessions_to_kill(&[], &BTreeSet::new()),
36
- KillDecision::KillIndividually { to_kill: vec![], spared: vec![] }
36
+ KillDecision::KillIndividually {
37
+ to_kill: vec![],
38
+ spared: vec![]
39
+ }
37
40
  );
38
41
  }
39
42
 
@@ -45,8 +48,16 @@ fn rc1_in_tmux_no_prefix_anchor_spares_user_session() {
45
48
  let decision = sessions_to_kill(&sessions, &anchor);
46
49
  match decision {
47
50
  KillDecision::KillIndividually { to_kill, spared } => {
48
- assert_eq!(to_kill, names(&["team-x"]), "only non-anchor session killed");
49
- assert_eq!(spared, names(&["team-coder-team"]), "user/leader session spared by anchor");
51
+ assert_eq!(
52
+ to_kill,
53
+ names(&["team-x"]),
54
+ "only non-anchor session killed"
55
+ );
56
+ assert_eq!(
57
+ spared,
58
+ names(&["team-coder-team"]),
59
+ "user/leader session spared by anchor"
60
+ );
50
61
  }
51
62
  other => panic!("anchor session must force per-session kill, not {other:?}"),
52
63
  }
@@ -106,7 +117,10 @@ fn union_prefix_and_anchor_no_double_count() {
106
117
  let decision = sessions_to_kill(&sessions, &anchor);
107
118
  assert_eq!(
108
119
  decision,
109
- KillDecision::KillIndividually { to_kill: vec![], spared: names(&["team-agent-leader-claude-ws-beef"]) }
120
+ KillDecision::KillIndividually {
121
+ to_kill: vec![],
122
+ spared: names(&["team-agent-leader-claude-ws-beef"])
123
+ }
110
124
  );
111
125
  }
112
126
 
@@ -136,12 +150,214 @@ fn missing_coordinator_is_ok_when_shutdown_cleaned_session() {
136
150
  .expect("shutdown should complete");
137
151
  assert_eq!(out["coordinator"]["status"], json!("missing"));
138
152
  assert_eq!(
139
- out["ok"], json!(true),
153
+ out["ok"],
154
+ json!(true),
140
155
  "coordinator.status=missing alone must not make a fully cleaned shutdown partial: {out}"
141
156
  );
142
157
  assert_eq!(out["status"], json!("ok"));
143
158
  assert_eq!(out["residuals"]["sessions"], json!([]));
144
159
  assert_eq!(out["residuals"]["processes"], json!([]));
160
+ assert_eq!(out["residuals"]["owned_files"], json!([]));
161
+ }
162
+
163
+ #[test]
164
+ fn owned_empty_endpoint_cleanup_removes_socket_file_before_reporting_ok() {
165
+ let ws = tmp_shutdown_workspace("owned-empty-endpoint-clean");
166
+ let socket = ws.join("owned.sock");
167
+ std::fs::write(&socket, b"socket").unwrap();
168
+ crate::state::persist::save_runtime_state(
169
+ &ws,
170
+ &json!({
171
+ "session_name": "team-owned-clean",
172
+ "tmux_endpoint": socket.to_string_lossy(),
173
+ "tmux_socket": socket.to_string_lossy(),
174
+ "tmux_socket_source": "workspace",
175
+ "is_external_leader": false,
176
+ "agents": {}
177
+ }),
178
+ )
179
+ .unwrap();
180
+ let transport = CleanShutdownTransport::new();
181
+
182
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
183
+ .expect("shutdown should complete");
184
+
185
+ assert_eq!(out["ok"], json!(true));
186
+ assert_eq!(out["status"], json!("ok"));
187
+ assert_eq!(out["residuals"]["owned_files"], json!([]));
188
+ assert!(
189
+ !socket.exists(),
190
+ "owned empty endpoint socket file must be removed"
191
+ );
192
+ assert!(
193
+ transport.kill_server_called(),
194
+ "owned empty endpoint should be torn down after session cleanup"
195
+ );
196
+ }
197
+
198
+ #[test]
199
+ fn leader_env_endpoint_is_not_owned_or_removed() {
200
+ let ws = tmp_shutdown_workspace("leader-env-endpoint-spared");
201
+ let socket = ws.join("leader-env.sock");
202
+ std::fs::write(&socket, b"socket").unwrap();
203
+ crate::state::persist::save_runtime_state(
204
+ &ws,
205
+ &json!({
206
+ "session_name": "team-external",
207
+ "tmux_endpoint": socket.to_string_lossy(),
208
+ "tmux_socket": socket.to_string_lossy(),
209
+ "tmux_socket_source": "leader_env",
210
+ "is_external_leader": true,
211
+ "agents": {}
212
+ }),
213
+ )
214
+ .unwrap();
215
+ let transport = CleanShutdownTransport::new();
216
+
217
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
218
+ .expect("shutdown should complete");
219
+
220
+ assert_eq!(out["ok"], json!(true));
221
+ assert_eq!(out["residuals"]["owned_files"], json!([]));
222
+ assert!(socket.exists(), "leader_env socket is not team-owned");
223
+ assert!(
224
+ !transport.kill_server_called(),
225
+ "leader_env/shared socket must never be torn down by owned cleanup"
226
+ );
227
+ }
228
+
229
+ #[test]
230
+ fn owned_file_residual_makes_shutdown_failed_not_partial() {
231
+ let ws = tmp_shutdown_workspace("owned-file-residual-failed");
232
+ let socket_dir = ws.join("owned-dir.sock");
233
+ std::fs::create_dir_all(&socket_dir).unwrap();
234
+ crate::state::persist::save_runtime_state(
235
+ &ws,
236
+ &json!({
237
+ "session_name": "team-owned-residual",
238
+ "tmux_endpoint": socket_dir.to_string_lossy(),
239
+ "tmux_socket": socket_dir.to_string_lossy(),
240
+ "tmux_socket_source": "workspace",
241
+ "is_external_leader": false,
242
+ "agents": {}
243
+ }),
244
+ )
245
+ .unwrap();
246
+ let transport = CleanShutdownTransport::new();
247
+
248
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
249
+ .expect("shutdown should complete");
250
+
251
+ assert_eq!(out["ok"], json!(false));
252
+ assert_eq!(out["status"], json!("failed"));
253
+ assert_eq!(out["phase"], json!(null));
254
+ assert_eq!(
255
+ out["residuals"]["owned_files"],
256
+ json!([{ "path": socket_dir.display().to_string() }])
257
+ );
258
+ }
259
+
260
+ #[test]
261
+ fn scoped_shutdown_keeps_owned_endpoint_when_sibling_session_remains() {
262
+ let ws = tmp_shutdown_workspace("scoped-owned-endpoint-sibling");
263
+ let socket = ws.join("owned-shared.sock");
264
+ std::fs::write(&socket, b"socket").unwrap();
265
+ crate::state::persist::save_runtime_state(
266
+ &ws,
267
+ &json!({
268
+ "active_team_key": "team-a",
269
+ "teams": {
270
+ "team-a": {
271
+ "team_key": "team-a",
272
+ "status": "alive",
273
+ "session_name": "team-a",
274
+ "tmux_endpoint": socket.to_string_lossy(),
275
+ "tmux_socket": socket.to_string_lossy(),
276
+ "tmux_socket_source": "workspace",
277
+ "is_external_leader": false,
278
+ "agents": {}
279
+ },
280
+ "team-b": {
281
+ "team_key": "team-b",
282
+ "status": "alive",
283
+ "session_name": "team-b",
284
+ "tmux_endpoint": socket.to_string_lossy(),
285
+ "tmux_socket": socket.to_string_lossy(),
286
+ "tmux_socket_source": "workspace",
287
+ "is_external_leader": false,
288
+ "agents": {}
289
+ }
290
+ }
291
+ }),
292
+ )
293
+ .unwrap();
294
+ let transport = CleanShutdownTransport::new()
295
+ .with_targets(vec![PaneInfo {
296
+ pane_id: PaneId::new("%2"),
297
+ session: SessionName::new("team-b"),
298
+ window_index: Some(0),
299
+ window_name: Some(WindowName::new("worker")),
300
+ pane_index: Some(0),
301
+ tty: None,
302
+ current_command: Some("fake".to_string()),
303
+ current_path: None,
304
+ active: true,
305
+ pane_pid: None,
306
+ leader_env: BTreeMap::new(),
307
+ }])
308
+ .with_targets_persist_after_kill();
309
+
310
+ let out =
311
+ crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, Some("team-a"), &transport)
312
+ .expect("shutdown should complete");
313
+
314
+ assert_eq!(out["ok"], json!(true));
315
+ assert_eq!(out["residuals"]["owned_files"], json!([]));
316
+ assert!(
317
+ socket.exists(),
318
+ "owned endpoint stays while sibling team session remains"
319
+ );
320
+ assert!(
321
+ !transport.kill_server_called(),
322
+ "scoped shutdown must not tear down a non-empty shared owned endpoint"
323
+ );
324
+ }
325
+
326
+ #[test]
327
+ fn repeated_owned_endpoint_shutdowns_leave_no_socket_file_growth() {
328
+ let ws = tmp_shutdown_workspace("owned-loop-no-growth");
329
+ let sockets = (0..20)
330
+ .map(|idx| ws.join(format!("owned-loop-{idx}.sock")))
331
+ .collect::<Vec<_>>();
332
+ let starting = sockets.iter().filter(|path| path.exists()).count();
333
+ for socket in &sockets {
334
+ std::fs::write(socket, b"socket").unwrap();
335
+ crate::state::persist::save_runtime_state(
336
+ &ws,
337
+ &json!({
338
+ "session_name": "team-owned-loop",
339
+ "tmux_endpoint": socket.to_string_lossy(),
340
+ "tmux_socket": socket.to_string_lossy(),
341
+ "tmux_socket_source": "workspace",
342
+ "is_external_leader": false,
343
+ "agents": {}
344
+ }),
345
+ )
346
+ .unwrap();
347
+ let out = crate::cli::lifecycle_port::shutdown_with_transport(
348
+ &ws,
349
+ true,
350
+ None,
351
+ &CleanShutdownTransport::new(),
352
+ )
353
+ .expect("shutdown should complete");
354
+ assert_eq!(out["ok"], json!(true), "shutdown report: {out}");
355
+ }
356
+ let ending = sockets.iter().filter(|path| path.exists()).count();
357
+ assert_eq!(
358
+ ending, starting,
359
+ "owned socket files must not grow across loops"
360
+ );
145
361
  }
146
362
 
147
363
  fn tmp_shutdown_workspace(tag: &str) -> PathBuf {
@@ -241,7 +457,10 @@ impl Transport for CleanShutdownTransport {
241
457
  _target: &Target,
242
458
  range: CaptureRange,
243
459
  ) -> Result<CapturedText, TransportError> {
244
- Ok(CapturedText { text: String::new(), range })
460
+ Ok(CapturedText {
461
+ text: String::new(),
462
+ range,
463
+ })
245
464
  }
246
465
 
247
466
  fn query(&self, _target: &Target, _field: PaneField) -> Result<Option<String>, TransportError> {
@@ -337,7 +556,9 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
337
556
  assert_eq!(out["residuals"]["sessions"], json!([]));
338
557
  assert_eq!(out["residuals"]["processes"], json!([]));
339
558
 
340
- let events = crate::event_log::EventLog::new(&ws).tail(0).expect("events");
559
+ let events = crate::event_log::EventLog::new(&ws)
560
+ .tail(0)
561
+ .expect("events");
341
562
  let shutdown = events
342
563
  .iter()
343
564
  .find(|event| {
@@ -412,6 +633,7 @@ fn shutdown_outcome_late_or_postcheck_gone_is_ok_with_lsof_diagnostic() {
412
633
  kill_error: false,
413
634
  session_residuals: false,
414
635
  process_residuals: false,
636
+ owned_file_residuals: false,
415
637
  cleanup_truth_degraded: false,
416
638
  coordinator_timeout: true,
417
639
  coordinator_stop_ok: None,
@@ -431,6 +653,7 @@ fn shutdown_outcome_coordinator_timeout_still_running_is_timeout() {
431
653
  kill_error: false,
432
654
  session_residuals: false,
433
655
  process_residuals: false,
656
+ owned_file_residuals: false,
434
657
  cleanup_truth_degraded: false,
435
658
  coordinator_timeout: true,
436
659
  coordinator_stop_ok: None,
@@ -450,6 +673,7 @@ fn shutdown_outcome_ps_table_degraded_still_partial_after_coordinator_gone() {
450
673
  kill_error: false,
451
674
  session_residuals: false,
452
675
  process_residuals: false,
676
+ owned_file_residuals: false,
453
677
  cleanup_truth_degraded: true,
454
678
  coordinator_timeout: true,
455
679
  coordinator_stop_ok: None,
@@ -116,6 +116,25 @@ fn detect_session_drift(
116
116
  .filter(|s| !s.trim().is_empty())?;
117
117
  let actual = extract_thread_id_from_scrollback(&fact.scrollback_tail)?;
118
118
  if actual.eq_ignore_ascii_case(stored) {
119
+ if agent_has_session_drift(state, &fact.agent_id)
120
+ || fact
121
+ .agent_state_snapshot
122
+ .get("status")
123
+ .and_then(Value::as_str)
124
+ == Some("session_drift")
125
+ {
126
+ clear_agent_session_drift(state, &fact.agent_id);
127
+ let _ = event_log.write(
128
+ "coordinator.session_drift_cleared",
129
+ json!({
130
+ "agent_id": fact.agent_id.as_str(),
131
+ "stored_session_id": stored,
132
+ "actual_thread_id": actual,
133
+ "status": "running",
134
+ "provider": "codex",
135
+ }),
136
+ );
137
+ }
119
138
  return None;
120
139
  }
121
140
  if fact
@@ -278,7 +297,9 @@ fn extract_thread_id_from_scrollback(scrollback: &str) -> Option<String> {
278
297
  while let Some(pos) = lower.get(offset..).and_then(|tail| tail.find(needle)) {
279
298
  let start = offset + pos + needle.len();
280
299
  if let Some(token) = first_token(scrollback.get(start..).unwrap_or_default()) {
281
- found = Some(token.to_ascii_lowercase());
300
+ if is_uuid_token(&token) {
301
+ found = Some(token.to_ascii_lowercase());
302
+ }
282
303
  }
283
304
  offset = start;
284
305
  }
@@ -297,11 +318,33 @@ fn first_token(text: &str) -> Option<String> {
297
318
  .unwrap_or(trimmed);
298
319
  let token: String = trimmed
299
320
  .chars()
300
- .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
321
+ .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
301
322
  .collect();
302
323
  (!token.is_empty()).then_some(token)
303
324
  }
304
325
 
326
+ fn is_uuid_token(token: &str) -> bool {
327
+ let bytes = token.as_bytes();
328
+ if bytes.len() != 36 {
329
+ return false;
330
+ }
331
+ for (idx, byte) in bytes.iter().enumerate() {
332
+ match idx {
333
+ 8 | 13 | 18 | 23 => {
334
+ if *byte != b'-' {
335
+ return false;
336
+ }
337
+ }
338
+ _ => {
339
+ if !byte.is_ascii_hexdigit() {
340
+ return false;
341
+ }
342
+ }
343
+ }
344
+ }
345
+ true
346
+ }
347
+
305
348
  fn mark_agent_session_drift(
306
349
  state: &mut Value,
307
350
  agent_id: &AgentId,
@@ -341,6 +384,49 @@ fn mark_agent_session_drift(
341
384
  }
342
385
  }
343
386
 
387
+ fn clear_agent_session_drift(state: &mut Value, agent_id: &AgentId) {
388
+ if let Some(agent) = agent_object_mut(state, agent_id) {
389
+ agent.insert("status".to_string(), Value::String("running".to_string()));
390
+ agent.remove("session_drift");
391
+ }
392
+ if let Some(teams) = state.get_mut("teams").and_then(Value::as_object_mut) {
393
+ for team in teams.values_mut() {
394
+ if let Some(agent) = team
395
+ .get_mut("agents")
396
+ .and_then(Value::as_object_mut)
397
+ .and_then(|agents| agents.get_mut(agent_id.as_str()))
398
+ .and_then(Value::as_object_mut)
399
+ {
400
+ agent.insert("status".to_string(), Value::String("running".to_string()));
401
+ agent.remove("session_drift");
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ fn agent_has_session_drift(state: &Value, agent_id: &AgentId) -> bool {
408
+ state
409
+ .get("agents")
410
+ .and_then(Value::as_object)
411
+ .and_then(|agents| agents.get(agent_id.as_str()))
412
+ .and_then(|agent| agent.get("status"))
413
+ .and_then(Value::as_str)
414
+ == Some("session_drift")
415
+ || state
416
+ .get("teams")
417
+ .and_then(Value::as_object)
418
+ .is_some_and(|teams| {
419
+ teams.values().any(|team| {
420
+ team.get("agents")
421
+ .and_then(Value::as_object)
422
+ .and_then(|agents| agents.get(agent_id.as_str()))
423
+ .and_then(|agent| agent.get("status"))
424
+ .and_then(Value::as_str)
425
+ == Some("session_drift")
426
+ })
427
+ })
428
+ }
429
+
344
430
  fn match_api_error(scrollback: &str) -> Option<(String, String)> {
345
431
  let lines: Vec<String> = scrollback
346
432
  .lines()
@@ -510,3 +596,166 @@ fn tail_chars(text: &str, max_chars: usize) -> String {
510
596
  let start = chars.len().saturating_sub(max_chars);
511
597
  chars[start..].iter().collect()
512
598
  }
599
+
600
+ #[cfg(test)]
601
+ mod tests {
602
+ use super::*;
603
+ use std::collections::BTreeMap;
604
+ use std::path::PathBuf;
605
+ use std::sync::atomic::{AtomicU64, Ordering};
606
+
607
+ use crate::model::ids::{AgentId, TeamKey};
608
+
609
+ const STORED: &str = "11111111-1111-4111-8111-111111111111";
610
+ const OTHER: &str = "22222222-2222-4222-8222-222222222222";
611
+
612
+ #[test]
613
+ fn session_drift_ignores_plain_text_after_marker_words() {
614
+ let workspace = temp_workspace("plain-text");
615
+ let mut state = state_with_agent("running", None);
616
+ let captures = captures("w1", STORED, "Need resume confirmation before changing anything.\nSeveral threads mention evidence.\n");
617
+
618
+ let observed = observe_runtime(&workspace, &mut state, captures, None);
619
+
620
+ assert!(
621
+ observed.session_drift.is_empty(),
622
+ "plain prose must not be treated as a runtime id observation: {observed:?}"
623
+ );
624
+ assert_eq!(
625
+ state.pointer("/agents/w1/status").and_then(Value::as_str),
626
+ Some("running")
627
+ );
628
+ assert!(state.pointer("/agents/w1/session_drift").is_none());
629
+ let _ = std::fs::remove_dir_all(workspace);
630
+ }
631
+
632
+ #[test]
633
+ fn session_drift_marks_real_uuid_mismatch() {
634
+ let workspace = temp_workspace("uuid-mismatch");
635
+ let mut state = state_with_agent("running", None);
636
+ let captures = captures(
637
+ "w1",
638
+ STORED,
639
+ &format!("Codex resumed. Switched to thread {OTHER}\n"),
640
+ );
641
+
642
+ let observed = observe_runtime(&workspace, &mut state, captures, None);
643
+
644
+ assert_eq!(observed.session_drift.len(), 1, "{observed:?}");
645
+ assert_eq!(
646
+ state.pointer("/agents/w1/status").and_then(Value::as_str),
647
+ Some("session_drift")
648
+ );
649
+ assert_eq!(
650
+ state
651
+ .pointer("/agents/w1/session_drift/actual_thread_id")
652
+ .and_then(Value::as_str),
653
+ Some(OTHER)
654
+ );
655
+ let _ = std::fs::remove_dir_all(workspace);
656
+ }
657
+
658
+ #[test]
659
+ fn session_drift_clears_existing_marker_when_uuid_matches() {
660
+ let workspace = temp_workspace("uuid-match-clear");
661
+ let mut state = state_with_agent(
662
+ "session_drift",
663
+ Some(json!({
664
+ "stored_session_id": STORED,
665
+ "actual_thread_id": OTHER,
666
+ "detected_at": "2026-06-13T00:00:00Z",
667
+ "remediation": "old"
668
+ })),
669
+ );
670
+ let captures = captures("w1", STORED, &format!("resume {STORED}\n"));
671
+
672
+ let observed = observe_runtime(&workspace, &mut state, captures, None);
673
+
674
+ assert!(
675
+ observed.session_drift.is_empty(),
676
+ "matching observation clears state instead of reporting a new drift: {observed:?}"
677
+ );
678
+ assert_eq!(
679
+ state.pointer("/agents/w1/status").and_then(Value::as_str),
680
+ Some("running")
681
+ );
682
+ assert!(state.pointer("/agents/w1/session_drift").is_none());
683
+ assert_eq!(
684
+ state
685
+ .pointer("/teams/current/agents/w1/status")
686
+ .and_then(Value::as_str),
687
+ Some("running")
688
+ );
689
+ assert!(state
690
+ .pointer("/teams/current/agents/w1/session_drift")
691
+ .is_none());
692
+ let _ = std::fs::remove_dir_all(workspace);
693
+ }
694
+
695
+ fn captures(
696
+ agent_id: &str,
697
+ stored_session_id: &str,
698
+ scrollback_tail: &str,
699
+ ) -> BTreeMap<AgentId, CapturedRuntimeFact> {
700
+ let agent_id = AgentId::new(agent_id);
701
+ let snapshot = json!({
702
+ "provider": "codex",
703
+ "status": "running",
704
+ "session_id": stored_session_id,
705
+ });
706
+ BTreeMap::from([(
707
+ agent_id.clone(),
708
+ CapturedRuntimeFact {
709
+ team_key: Some(TeamKey::new("current")),
710
+ agent_id,
711
+ provider: Some(Provider::Codex),
712
+ session_name: None,
713
+ window: None,
714
+ pane_id: None,
715
+ scrollback_tail: scrollback_tail.to_string(),
716
+ pane_info: None,
717
+ agent_state_snapshot: snapshot,
718
+ stored_session_id: Some(stored_session_id.to_string()),
719
+ last_output_at: None,
720
+ rollout_path: None,
721
+ process_liveness: None,
722
+ },
723
+ )])
724
+ }
725
+
726
+ fn state_with_agent(status: &str, session_drift: Option<Value>) -> Value {
727
+ let mut agent = json!({
728
+ "provider": "codex",
729
+ "status": status,
730
+ "session_id": STORED,
731
+ });
732
+ if let Some(drift) = session_drift {
733
+ agent["session_drift"] = drift;
734
+ }
735
+ json!({
736
+ "active_team_key": "current",
737
+ "agents": {
738
+ "w1": agent.clone()
739
+ },
740
+ "teams": {
741
+ "current": {
742
+ "agents": {
743
+ "w1": agent
744
+ }
745
+ }
746
+ }
747
+ })
748
+ }
749
+
750
+ fn temp_workspace(tag: &str) -> PathBuf {
751
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
752
+ let dir = std::env::temp_dir().join(format!(
753
+ "ta-rs-e28-{tag}-{}-{}",
754
+ std::process::id(),
755
+ COUNTER.fetch_add(1, Ordering::Relaxed)
756
+ ));
757
+ let _ = std::fs::remove_dir_all(&dir);
758
+ std::fs::create_dir_all(&dir).unwrap();
759
+ std::fs::canonicalize(dir).unwrap()
760
+ }
761
+ }