@team-agent/installer 0.4.10 → 0.5.0

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 (74) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +62 -4
  6. package/crates/team-agent/src/compiler.rs +11 -20
  7. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  9. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  10. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  11. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  12. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  13. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  14. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  15. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  17. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  18. package/crates/team-agent/src/leader/start.rs +169 -25
  19. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  20. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  21. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  22. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  23. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  24. package/crates/team-agent/src/lifecycle/restart/agent.rs +436 -121
  25. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  26. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  27. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  28. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  29. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +193 -0
  30. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  31. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  32. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +54 -15
  33. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  34. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
  35. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  36. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  37. package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
  38. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  39. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  40. package/crates/team-agent/src/messaging/activity.rs +43 -18
  41. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  42. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  44. package/crates/team-agent/src/messaging/results.rs +34 -9
  45. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  46. package/crates/team-agent/src/model/spec.rs +10 -6
  47. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  48. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  49. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  50. package/crates/team-agent/src/provider/classify.rs +23 -48
  51. package/crates/team-agent/src/provider/command.rs +16 -7
  52. package/crates/team-agent/src/provider/faults.rs +93 -53
  53. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  54. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  55. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  56. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  57. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  58. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  59. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  60. package/crates/team-agent/src/provider/types.rs +33 -1
  61. package/crates/team-agent/src/provider/wire.rs +171 -1
  62. package/crates/team-agent/src/state/identity.rs +242 -57
  63. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  64. package/crates/team-agent/src/state/mod.rs +5 -1
  65. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  66. package/crates/team-agent/src/state/ownership.rs +12 -11
  67. package/crates/team-agent/src/state/paths.rs +13 -6
  68. package/crates/team-agent/src/state/persist.rs +671 -128
  69. package/crates/team-agent/src/state/projection.rs +240 -49
  70. package/crates/team-agent/src/state/selector.rs +11 -5
  71. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  72. package/crates/team-agent/src/tmux_backend.rs +42 -5
  73. package/crates/team-agent/src/transport/test_support.rs +55 -6
  74. package/package.json +4 -4
@@ -322,6 +322,8 @@ log="${TEAM_AGENT_E27_TMUX_LOG}"
322
322
  expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
323
323
  session="${TEAM_AGENT_E27_SESSION_NAME}"
324
324
  pane="${TEAM_AGENT_E27_PANE_ID}"
325
+ killed_marker="${log}.killed"
326
+ spawned_marker="${log}.spawned"
325
327
  track=0
326
328
  case "$*" in
327
329
  *"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
@@ -340,18 +342,44 @@ case "$*" in
340
342
  exit 18
341
343
  ;;
342
344
  esac
345
+ # 0.4.10+ reset-gate fix: track kill-pane so the next display-message
346
+ # probe reports the pane as gone. The reset hard gate calls has_pane
347
+ # after stop_agent_at_paths returns; a shim that always reports the
348
+ # original pane as live would trip the gate.
349
+ case "$*" in
350
+ *"kill-pane -t $pane"*)
351
+ : > "$killed_marker"
352
+ exit 0
353
+ ;;
354
+ esac
343
355
  case "$*" in
344
356
  *"display-message -p -t $pane #{pane_id}"*)
357
+ if [ -f "$killed_marker" ]; then
358
+ echo "can't find pane: $pane" >&2
359
+ exit 1
360
+ fi
345
361
  printf '%s\n' "$pane"
346
362
  exit 0
347
363
  ;;
348
364
  *"display-message -p -t $session:alpha #{pane_id}"*)
365
+ : > "$spawned_marker"
349
366
  printf '%%9288\n'
350
367
  exit 0
351
368
  ;;
352
369
  *"list-windows -t $session -F #{window_name}"*)
353
370
  exit 0
354
371
  ;;
372
+ *"list-panes -a -F"*)
373
+ if [ -f "$spawned_marker" ]; then
374
+ printf '%%9288\t%s\t0\talpha\t0\t/dev/ttys099\tnode\t1\t/tmp\t1\t0\t9288\n' "$session"
375
+ exit 0
376
+ fi
377
+ # Hard-gate post-stop snapshot: no residual same-role panes after kill.
378
+ if [ -f "$killed_marker" ]; then
379
+ exit 0
380
+ fi
381
+ exit 0
382
+ ;;
355
383
  *"has-session -t $session"*)
356
384
  exit 0
357
385
  ;;
@@ -609,6 +637,171 @@ fn reset_agent_emits_golden_lifecycle_event_payloads() {
609
637
  assert_eq!(complete.get("agent_id").and_then(|v| v.as_str()), Some("alpha"));
610
638
  }
611
639
 
640
+ // 0.4.10+ reset duplicate-window fix RED tests
641
+ // (plan §1-§3, CR C-1/C-2/C-5 acceptance).
642
+
643
+ /// Plan §2: stop must resolve a stale pane_id to live same-role panes
644
+ /// and kill them by pane_id. Pre-fix path returned stopped=false when
645
+ /// pane_id was stale even if a residue survived; this lets reset's
646
+ /// unconditional start spawn a duplicate window.
647
+ ///
648
+ /// With the fix: stale pane_id + same-role residue → stop enumerates,
649
+ /// kills by pane_id, returns stopped=true. Reset proceeds normally.
650
+ #[test]
651
+ fn reset_agent_stale_pane_with_same_role_residue_kills_by_pane_id() {
652
+ use crate::transport::{PaneInfo, PaneId as PaneIdT, SessionName, WindowName};
653
+ let ws = lanea_team_ws("running");
654
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
655
+ state["agents"]["alpha"]["pane_id"] = json!("%STALE");
656
+ state["agents"]["alpha"]["window"] = json!("alpha");
657
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
658
+
659
+ // Transport: stale %STALE absent; same-role residual %RESIDUAL listed.
660
+ let residual = PaneInfo {
661
+ pane_id: PaneIdT::new("%RESIDUAL"),
662
+ session: SessionName::new("team-laneateam"),
663
+ window_index: Some(0),
664
+ window_name: Some(WindowName::new("alpha")),
665
+ pane_index: None,
666
+ tty: None,
667
+ current_command: None,
668
+ current_path: None,
669
+ active: true,
670
+ pane_pid: None,
671
+ leader_env: std::collections::BTreeMap::new(),
672
+ };
673
+ let transport = OfflineTransport::new()
674
+ .with_session_present(true)
675
+ .with_targets(vec![residual.clone()])
676
+ .with_pane_presence("%STALE", false);
677
+
678
+ // The stop sub-step must enumerate same-role panes and kill %RESIDUAL.
679
+ // The reset overall proceeds (stop.stopped=true → gate skipped).
680
+ let outcome = crate::lifecycle::reset_agent_with_transport(
681
+ &ws,
682
+ &aid("alpha"),
683
+ true,
684
+ false,
685
+ None,
686
+ &transport,
687
+ );
688
+ let outcome_dbg = format!("{outcome:?}");
689
+ // Outcome may be Ok or downstream Err from spawn, but it MUST NOT be
690
+ // the stop-not-proven RequirementUnmet (the gate did not need to fire
691
+ // because stop correctly killed the residue).
692
+ assert!(
693
+ !outcome_dbg.contains("old agent instance still live"),
694
+ "stop must kill stale-pane residue (no stop-not-proven gate \
695
+ trigger); got {outcome_dbg}"
696
+ );
697
+ // The stop_complete event must report stopped=true (residual was killed).
698
+ let events = lifecycle_events(&ws);
699
+ let stop_complete = find_event(&events, "stop_agent.complete")
700
+ .expect("stop_agent.complete must be emitted");
701
+ assert_eq!(
702
+ stop_complete.get("stopped").and_then(|v| v.as_bool()),
703
+ Some(true),
704
+ "stop must report stopped=true when same-role residue was killed; \
705
+ got {stop_complete:?}"
706
+ );
707
+ }
708
+
709
+ /// Plan §3 hard gate: structural verification — the helpers exist and
710
+ /// are wired into reset_agent_at_paths. The full race-condition scenario
711
+ /// (stop returns stopped=false BUT residue appears between stop and
712
+ /// gate-time list_targets snapshot) requires concurrency the
713
+ /// OfflineTransport cannot model — the live macmini batch reset
714
+ /// evidence (.team/evidence/) is the truth source for that race. The
715
+ /// unit test below verifies the gate code path is reachable + the
716
+ /// event writer + N38 error text via a synthetic state with a still-live
717
+ /// stored pane_id that stop's kill_pane targeted but the
718
+ /// post-stop has_pane re-reads as live (a deterministic mock).
719
+ #[test]
720
+ fn reset_agent_stop_not_proven_grep_guard_hard_gate_wired() {
721
+ let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
722
+ let agent_rs = manifest
723
+ .join("src")
724
+ .join("lifecycle")
725
+ .join("restart")
726
+ .join("agent.rs");
727
+ let contents = std::fs::read_to_string(&agent_rs).expect("read agent.rs");
728
+ // Gate must exist with the N38 three-line text and stop_not_proven event.
729
+ assert!(
730
+ contents.contains("reset refused: old agent instance still live"),
731
+ "reset_agent_at_paths must carry the N38 three-line error (CR C-1)"
732
+ );
733
+ assert!(
734
+ contents.contains("write_reset_stop_not_proven_event"),
735
+ "reset_agent_at_paths must emit reset_agent.stop_not_proven via the writer"
736
+ );
737
+ assert!(
738
+ contents.contains("if !agent_is_paused && !stop.stopped"),
739
+ "gate must run only when stop.stopped=false (the dangerous case)"
740
+ );
741
+ assert!(
742
+ contents.contains("list_same_role_panes")
743
+ && contents.contains("is_per_agent_window"),
744
+ "stop_agent_at_paths must enumerate same-role panes via the new helpers"
745
+ );
746
+ }
747
+
748
+ /// Plan §3 hard gate: when no residue (clean stop), reset is allowed to
749
+ /// proceed to start. Covers the legitimate `stopped=false` case (worker
750
+ /// already absent — nothing to gate, nothing to refuse).
751
+ #[test]
752
+ fn reset_agent_already_absent_can_start() {
753
+ let ws = lanea_team_ws("stopped");
754
+ // No stale pane_id, no residual panes — gate is a no-op.
755
+ let transport = OfflineTransport::new().with_session_present(true);
756
+ let outcome = crate::lifecycle::reset_agent_with_transport(
757
+ &ws,
758
+ &aid("alpha"),
759
+ true,
760
+ false,
761
+ None,
762
+ &transport,
763
+ );
764
+ // The transport is offline so spawn may fail downstream; the contract
765
+ // for THIS test is that the hard gate did NOT short-circuit to
766
+ // RequirementUnmet with "old agent instance still live".
767
+ let outcome_dbg = format!("{outcome:?}");
768
+ assert!(
769
+ !outcome_dbg.contains("old agent instance still live"),
770
+ "reset with no residue must NOT trigger the stop-not-proven gate; \
771
+ got {outcome_dbg}"
772
+ );
773
+ // The stop_not_proven event must NOT be present.
774
+ let events = lifecycle_events(&ws);
775
+ assert!(
776
+ find_event(&events, "reset_agent.stop_not_proven").is_none(),
777
+ "no reset_agent.stop_not_proven event when nothing to gate; \
778
+ events seen: {:?}",
779
+ names(&events)
780
+ );
781
+ }
782
+
783
+ /// Plan §2 + CR C-5: standalone stop-agent must keep "already absent is
784
+ /// stopped=false, ok" behavior. The hard gate only runs in reset, not
785
+ /// in stop-agent. This locks the boundary so the fix does not regress
786
+ /// existing stop-agent contract.
787
+ #[test]
788
+ fn stop_agent_already_absent_still_returns_stopped_false() {
789
+ let ws = lanea_team_ws("stopped");
790
+ let transport = OfflineTransport::new().with_session_present(true);
791
+ let result = crate::lifecycle::stop_agent_with_transport(
792
+ &ws,
793
+ &aid("alpha"),
794
+ None,
795
+ &transport,
796
+ );
797
+ let report = result.expect("stop_agent must return Ok for absent agent");
798
+ assert!(
799
+ !report.stopped,
800
+ "stop_agent on already-absent worker returns stopped=false (no \
801
+ kill, contract preserved); got {report:?}"
802
+ );
803
+ }
804
+
612
805
  // stop-agent — golden operations.py:98 writes stop_agent.complete {agent_id, target, stopped}
613
806
  // (+ stop_agent.window_stop_failed {agent_id, target, stderr} on kill failure). Rust emits none.
614
807
  #[test]
@@ -27,10 +27,10 @@ impl LaneTransport {
27
27
  spawns: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
28
28
  }
29
29
  }
30
- fn killed(&self) -> Vec<String> {
30
+ pub(super) fn killed(&self) -> Vec<String> {
31
31
  self.killed.lock().unwrap().clone()
32
32
  }
33
- fn spawns(&self) -> Vec<(String, Vec<String>)> {
33
+ pub(super) fn spawns(&self) -> Vec<(String, Vec<String>)> {
34
34
  self.spawns.lock().unwrap().clone()
35
35
  }
36
36
  }
@@ -164,7 +164,7 @@ fn set_context_state_file(ws: &std::path::Path, name: &str) {
164
164
  /// Fork workspace: source `alpha` (role doc = `alpha_role`) + `bravo`, alpha seeded RUNNING with a
165
165
  /// source `session_id` (so fork reaches the spec-mutation / native-fork gate). Seeded-healthy coordinator
166
166
  /// (fork's start_coordinator -> AlreadyRunning, no real daemon). session_name = "team-laneateam".
167
- fn fork_ws(alpha_role: &str) -> PathBuf {
167
+ pub(super) fn fork_ws(alpha_role: &str) -> PathBuf {
168
168
  let ws = temp_ws().join("forkv2");
169
169
  std::fs::create_dir_all(ws.join("agents")).unwrap();
170
170
  std::fs::write(ws.join("TEAM.md"), "---\nname: laneateam\nobjective: Fork v2 probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
@@ -0,0 +1,321 @@
1
+ use std::path::{Path, PathBuf};
2
+ use std::time::Duration;
3
+
4
+ use super::*;
5
+ use crate::lifecycle::lock::{
6
+ acquire_agent_lifecycle_lock, acquire_agent_lifecycle_lock_for_test, LifecycleLockRequest,
7
+ AGENT_LIFECYCLE_LOCK_NAME, LIFECYCLE_LOCK_HELD_LONG, LIFECYCLE_LOCK_TIMEOUT,
8
+ };
9
+
10
+ #[test]
11
+ fn lifecycle_lock_timeout_error_contract_and_constants() {
12
+ assert_eq!(AGENT_LIFECYCLE_LOCK_NAME, "agent-lifecycle");
13
+ assert_eq!(LIFECYCLE_LOCK_TIMEOUT, Duration::from_secs(30));
14
+ assert_eq!(LIFECYCLE_LOCK_HELD_LONG, Duration::from_secs(5));
15
+
16
+ let ws = temp_ws();
17
+ let err = LifecycleError::LifecycleLockTimeout {
18
+ lock_path: crate::model::paths::runtime_dir(&ws).join("agent-lifecycle.lock"),
19
+ log_path: crate::model::paths::logs_dir(&ws).join("events.jsonl"),
20
+ operation: "reset-agent".to_string(),
21
+ waited_ms: 30_000,
22
+ };
23
+ assert_eq!(
24
+ err.to_string(),
25
+ format!(
26
+ "error: lifecycle lock timeout after 30s\naction: retry or check for hung reset/start\nlog: {}",
27
+ crate::model::paths::logs_dir(&ws).join("events.jsonl").display()
28
+ )
29
+ );
30
+ }
31
+
32
+ #[test]
33
+ fn lifecycle_lock_lifecycle_rollback_releases_after_timeout() {
34
+ let ws = temp_ws();
35
+ let agent = aid("alpha");
36
+ let held = acquire_agent_lifecycle_lock(LifecycleLockRequest {
37
+ workspace: &ws,
38
+ operation: "reset-agent",
39
+ team: Some("alpha"),
40
+ agent_id: Some(&agent),
41
+ })
42
+ .expect("hold lifecycle lock");
43
+
44
+ let waiter_ws = ws.clone();
45
+ let waiter_agent = aid("beta");
46
+ let result = std::thread::spawn(move || {
47
+ acquire_agent_lifecycle_lock_for_test(
48
+ LifecycleLockRequest {
49
+ workspace: &waiter_ws,
50
+ operation: "start-agent",
51
+ team: Some("alpha"),
52
+ agent_id: Some(&waiter_agent),
53
+ },
54
+ Duration::from_millis(200),
55
+ Duration::from_secs(5),
56
+ )
57
+ })
58
+ .join()
59
+ .expect("waiter thread");
60
+
61
+ assert!(
62
+ matches!(result, Err(LifecycleError::LifecycleLockTimeout { operation, .. }) if operation == "start-agent"),
63
+ "second lifecycle mutator must time out while lock is held"
64
+ );
65
+
66
+ drop(held);
67
+ acquire_agent_lifecycle_lock_for_test(
68
+ LifecycleLockRequest {
69
+ workspace: &ws,
70
+ operation: "stop-agent",
71
+ team: Some("alpha"),
72
+ agent_id: Some(&agent),
73
+ },
74
+ Duration::from_millis(200),
75
+ Duration::from_secs(5),
76
+ )
77
+ .expect("lock released after guard drop");
78
+ }
79
+
80
+ #[test]
81
+ fn lifecycle_lock_long_held_event_records_holder_and_waiter() {
82
+ let ws = temp_ws();
83
+ let holder_agent = aid("holder");
84
+ let waiter_agent = aid("waiter");
85
+ let _held = acquire_agent_lifecycle_lock(LifecycleLockRequest {
86
+ workspace: &ws,
87
+ operation: "restart",
88
+ team: Some("alpha"),
89
+ agent_id: Some(&holder_agent),
90
+ })
91
+ .expect("hold lifecycle lock");
92
+
93
+ let waiter_ws = ws.clone();
94
+ let result = std::thread::spawn(move || {
95
+ acquire_agent_lifecycle_lock_for_test(
96
+ LifecycleLockRequest {
97
+ workspace: &waiter_ws,
98
+ operation: "reset-agent",
99
+ team: Some("alpha"),
100
+ agent_id: Some(&waiter_agent),
101
+ },
102
+ Duration::from_millis(220),
103
+ Duration::from_millis(50),
104
+ )
105
+ })
106
+ .join()
107
+ .expect("waiter thread");
108
+ assert!(matches!(
109
+ result,
110
+ Err(LifecycleError::LifecycleLockTimeout { .. })
111
+ ));
112
+
113
+ let events = read_events(&ws);
114
+ let event = events
115
+ .iter()
116
+ .find(|event| {
117
+ event.get("event").and_then(serde_json::Value::as_str)
118
+ == Some("lifecycle.lock_held_long")
119
+ })
120
+ .unwrap_or_else(|| panic!("missing lifecycle.lock_held_long event: {events:?}"));
121
+ assert_eq!(event["lock_name"], json!("agent-lifecycle"));
122
+ assert_eq!(event["operation"], json!("reset-agent"));
123
+ assert_eq!(event["holder"]["operation"], json!("restart"));
124
+ assert_eq!(event["holder"]["agent_id"], json!("holder"));
125
+ assert_eq!(event["threshold_ms"], json!(50));
126
+ assert_eq!(event["timeout_ms"], json!(220));
127
+ assert!(
128
+ event["blocked_queue_len"].as_u64().unwrap_or(0) >= 1,
129
+ "waiter sidecar should be visible while long-held event is emitted"
130
+ );
131
+ }
132
+
133
+ #[test]
134
+ fn lifecycle_lock_phase_b_golden_source_guard() {
135
+ r2_lifecycle_lock_exists_precondition();
136
+ }
137
+
138
+ #[test]
139
+ fn r2_lifecycle_lock_exists_precondition() {
140
+ let src = manifest_src();
141
+ let lock = read_src("lifecycle/lock.rs");
142
+ assert!(
143
+ lock.contains("AGENT_LIFECYCLE_LOCK_NAME: &str = \"agent-lifecycle\""),
144
+ "lifecycle lock name constant must remain explicit"
145
+ );
146
+ assert!(
147
+ lock.contains("LIFECYCLE_LOCK_TIMEOUT: Duration = Duration::from_secs(30)"),
148
+ "lifecycle lock timeout constant must remain 30s"
149
+ );
150
+ assert!(
151
+ lock.contains("write_lock_held_long_event") && lock.contains("lifecycle.lock_held_long"),
152
+ "long-held lifecycle lock event must remain wired"
153
+ );
154
+
155
+ let state_hits = source_files(&src.join("state"))
156
+ .into_iter()
157
+ .filter(|path| {
158
+ let text = std::fs::read_to_string(path).unwrap();
159
+ text.contains("RuntimeLock::acquire") && text.contains("\"state-save\"")
160
+ })
161
+ .collect::<Vec<_>>();
162
+ assert_eq!(
163
+ state_hits,
164
+ vec![src.join("state").join("persist.rs")],
165
+ "state-save lock must stay owned by state/persist.rs"
166
+ );
167
+
168
+ for path in source_files(&src.join("state")) {
169
+ let text = std::fs::read_to_string(&path).unwrap();
170
+ assert!(
171
+ !text.contains("LifecycleLock")
172
+ && !text.contains("acquire_agent_lifecycle_lock")
173
+ && !text.contains("agent-lifecycle"),
174
+ "state module must not acquire or know lifecycle lock: {}",
175
+ path.display()
176
+ );
177
+ }
178
+
179
+ for path in source_files(&src.join("coordinator")) {
180
+ let text = std::fs::read_to_string(&path).unwrap();
181
+ assert!(
182
+ !text.contains("acquire_agent_lifecycle_lock") && !text.contains("agent-lifecycle"),
183
+ "coordinator tick path must not block on lifecycle lock: {}",
184
+ path.display()
185
+ );
186
+ }
187
+
188
+ for path in source_files(&src) {
189
+ if path == src.join("state").join("persist.rs") || is_test_source(&path) {
190
+ continue;
191
+ }
192
+ let text = std::fs::read_to_string(&path).unwrap();
193
+ assert!(
194
+ !text.contains("RuntimeLock::acquire"),
195
+ "production source outside state/persist.rs must not acquire runtime locks directly: {}",
196
+ path.display()
197
+ );
198
+ }
199
+
200
+ for path in source_files(&src) {
201
+ if path.starts_with(src.join("state")) || path.starts_with(src.join("lifecycle")) {
202
+ continue;
203
+ }
204
+ let text = std::fs::read_to_string(&path).unwrap();
205
+ let lifecycle_topology_authority_entries = [
206
+ "save_runtime_state_with_lifecycle_topology_authority",
207
+ "save_team_scoped_state_with_lifecycle_topology_authority",
208
+ "save_runtime_state_with_team_tombstone_lifecycle_topology_authority",
209
+ "save_team_scoped_state_with_tombstone_lifecycle_topology_authority",
210
+ ];
211
+ assert!(
212
+ !lifecycle_topology_authority_entries
213
+ .iter()
214
+ .any(|entry| text.contains(entry)),
215
+ "lifecycle topology authority save entry must not be referenced outside lifecycle/state modules: {}",
216
+ path.display()
217
+ );
218
+ }
219
+
220
+ let agent = read_src("lifecycle/restart/agent.rs");
221
+ assert_public_operation(&agent, "start-agent");
222
+ assert_public_operation(&agent, "stop-agent");
223
+ assert_public_operation(&agent, "reset-agent");
224
+ assert_body_is_unlocked(&agent, "pub(crate) fn start_agent_at_paths");
225
+ assert_body_is_unlocked(&agent, "pub(super) fn stop_agent_at_paths");
226
+ assert_body_is_unlocked(&agent, "fn reset_agent_at_paths");
227
+
228
+ let remove = read_src("lifecycle/restart/remove.rs");
229
+ assert_public_operation(&remove, "remove-agent");
230
+ assert_body_is_unlocked(&remove, "fn remove_agent_at_paths");
231
+ assert!(
232
+ !remove.contains("start_agent_with_transport"),
233
+ "remove rollback runs while remove-agent holds the lifecycle lock; it must use lock-free start_agent_at_paths"
234
+ );
235
+
236
+ let launch = read_src("lifecycle/launch.rs");
237
+ assert_public_operation(&launch, "add-agent");
238
+ assert_public_operation(&launch, "fork-agent");
239
+ assert_body_is_unlocked(&launch, "fn add_agent_with_transport_at_paths");
240
+
241
+ let rebuild = read_src("lifecycle/restart/rebuild.rs");
242
+ assert_public_operation(&rebuild, "restart");
243
+ let drop_idx = rebuild.find("drop(lifecycle_lock);").unwrap();
244
+ let coordinator_idx = rebuild
245
+ .find("start_coordinator_for_workspace(&selected.run_workspace)")
246
+ .unwrap();
247
+ let readiness_idx = rebuild.find("wait_restart_readiness_or_timeout(").unwrap();
248
+ assert!(
249
+ rebuild.contains("let lifecycle_lock = acquire_agent_lifecycle_lock")
250
+ && drop_idx < coordinator_idx
251
+ && coordinator_idx < readiness_idx,
252
+ "restart must release lifecycle lock before coordinator readiness wait"
253
+ );
254
+ }
255
+
256
+ fn is_test_source(path: &Path) -> bool {
257
+ path.components()
258
+ .any(|component| component.as_os_str() == "tests")
259
+ || path
260
+ .file_name()
261
+ .and_then(|name| name.to_str())
262
+ .is_some_and(|name| name == "tests.rs" || name.ends_with("_tests.rs"))
263
+ }
264
+
265
+ fn read_events(workspace: &Path) -> Vec<serde_json::Value> {
266
+ let path = crate::model::paths::logs_dir(workspace).join("events.jsonl");
267
+ std::fs::read_to_string(path)
268
+ .unwrap_or_default()
269
+ .lines()
270
+ .filter_map(|line| serde_json::from_str(line).ok())
271
+ .collect()
272
+ }
273
+
274
+ fn manifest_src() -> PathBuf {
275
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src")
276
+ }
277
+
278
+ fn read_src(path: &str) -> String {
279
+ std::fs::read_to_string(manifest_src().join(path)).unwrap()
280
+ }
281
+
282
+ fn source_files(root: &Path) -> Vec<PathBuf> {
283
+ let mut out = Vec::new();
284
+ collect_rs_files(root, &mut out);
285
+ out.sort();
286
+ out
287
+ }
288
+
289
+ fn collect_rs_files(root: &Path, out: &mut Vec<PathBuf>) {
290
+ for entry in std::fs::read_dir(root).unwrap() {
291
+ let path = entry.unwrap().path();
292
+ if path.is_dir() {
293
+ collect_rs_files(&path, out);
294
+ } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
295
+ out.push(path);
296
+ }
297
+ }
298
+ }
299
+
300
+ fn assert_public_operation(source: &str, operation: &str) {
301
+ assert!(
302
+ source.contains("acquire_agent_lifecycle_lock")
303
+ && source.contains(&format!("operation: \"{operation}\"")),
304
+ "public lifecycle entry must acquire lifecycle lock for {operation}"
305
+ );
306
+ }
307
+
308
+ fn assert_body_is_unlocked(source: &str, signature: &str) {
309
+ let body = source
310
+ .split_once(signature)
311
+ .unwrap_or_else(|| panic!("missing signature: {signature}"))
312
+ .1;
313
+ let body = body
314
+ .split_once("\n}\n")
315
+ .map(|(body, _)| body)
316
+ .unwrap_or(body);
317
+ assert!(
318
+ !body.contains("acquire_agent_lifecycle_lock"),
319
+ "{signature} must remain lock-free to avoid nested reset deadlocks"
320
+ );
321
+ }
@@ -33,7 +33,14 @@ type RecordedSpawns = LaneSpawns;
33
33
  /// every spawn (kind + argv) like `RecordingLaunchTransport`.
34
34
  struct SessionProbeRecordingTransport {
35
35
  spawns: RecordedSpawns,
36
+ spawn_targets: std::sync::Mutex<Vec<(String, String, String)>>,
36
37
  session_exists: bool,
38
+ // 0.4.10+ reset hard-gate: record killed panes so liveness/has_pane
39
+ // reflect the kill (the gate checks "did stop actually remove the
40
+ // old pane"). Default mock behavior reported Live unconditionally —
41
+ // safe pre-fix because no gate ran, but the gate now needs the kill
42
+ // to be observable.
43
+ killed_panes: std::sync::Mutex<std::collections::BTreeSet<String>>,
37
44
  }
38
45
  impl crate::transport::Transport for SessionProbeRecordingTransport {
39
46
  fn kind(&self) -> crate::transport::BackendKind {
@@ -41,11 +48,22 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
41
48
  }
42
49
  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
50
  self.spawns.lock().unwrap().push(("spawn_first".to_string(), argv.to_vec()));
51
+ self.spawn_targets.lock().unwrap().push((
52
+ session.as_str().to_string(),
53
+ window.as_str().to_string(),
54
+ "%0".to_string(),
55
+ ));
44
56
  Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new("%0"), session: session.clone(), window: window.clone(), child_pid: Some(6856) })
45
57
  }
46
58
  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
59
  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: Some(6856) })
60
+ let pane = format!("%{}", window.as_str());
61
+ self.spawn_targets.lock().unwrap().push((
62
+ session.as_str().to_string(),
63
+ window.as_str().to_string(),
64
+ pane.clone(),
65
+ ));
66
+ Ok(crate::transport::SpawnResult { pane_id: crate::transport::PaneId::new(pane), session: session.clone(), window: window.clone(), child_pid: Some(6856) })
49
67
  }
50
68
  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
69
  unimplemented!("not reached by start_agent respawn")
@@ -62,11 +80,41 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
62
80
  fn query(&self, _t: &crate::transport::Target, _f: crate::transport::PaneField) -> Result<Option<String>, crate::transport::TransportError> {
63
81
  unimplemented!("not reached")
64
82
  }
65
- fn liveness(&self, _p: &crate::transport::PaneId) -> Result<crate::model::enums::PaneLiveness, crate::transport::TransportError> {
83
+ fn liveness(&self, p: &crate::transport::PaneId) -> Result<crate::model::enums::PaneLiveness, crate::transport::TransportError> {
84
+ let killed = self.killed_panes.lock().unwrap();
85
+ if killed.contains(p.as_str()) {
86
+ return Ok(crate::model::enums::PaneLiveness::Dead);
87
+ }
66
88
  Ok(crate::model::enums::PaneLiveness::Live)
67
89
  }
90
+ fn has_pane(&self, p: &crate::transport::PaneId) -> Result<Option<bool>, crate::transport::TransportError> {
91
+ let killed = self.killed_panes.lock().unwrap();
92
+ Ok(Some(!killed.contains(p.as_str())))
93
+ }
94
+ fn kill_pane(&self, p: &crate::transport::PaneId) -> Result<(), crate::transport::TransportError> {
95
+ self.killed_panes.lock().unwrap().insert(p.as_str().to_string());
96
+ Ok(())
97
+ }
68
98
  fn list_targets(&self) -> Result<Vec<crate::transport::PaneInfo>, crate::transport::TransportError> {
69
- Ok(Vec::new())
99
+ Ok(self
100
+ .spawn_targets
101
+ .lock()
102
+ .unwrap()
103
+ .iter()
104
+ .map(|(session, window, pane)| crate::transport::PaneInfo {
105
+ pane_id: crate::transport::PaneId::new(pane.as_str()),
106
+ session: crate::transport::SessionName::new(session.as_str()),
107
+ window_index: None,
108
+ window_name: Some(crate::transport::WindowName::new(window.as_str())),
109
+ pane_index: None,
110
+ tty: None,
111
+ current_command: Some("node".to_string()),
112
+ current_path: None,
113
+ active: false,
114
+ pane_pid: Some(6856),
115
+ leader_env: std::collections::BTreeMap::new(),
116
+ })
117
+ .collect())
70
118
  }
71
119
  fn has_session(&self, _s: &crate::transport::SessionName) -> Result<bool, crate::transport::TransportError> {
72
120
  Ok(self.session_exists)
@@ -115,10 +163,7 @@ fn start_agent_respawn_into_dead_session_uses_new_session_not_new_window() {
115
163
  let ws = respawn_ws_one_resumable_worker();
116
164
  let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
117
165
  // session_exists=false models the dead CP-1 `-L` server after --discard-session.
118
- let transport = SessionProbeRecordingTransport {
119
- spawns: std::sync::Arc::clone(&spawns),
120
- session_exists: false,
121
- };
166
+ let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), spawn_targets: std::sync::Mutex::new(Vec::new()), session_exists: false, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
122
167
  let _ = start_agent_with_transport(&ws, &AgentId::new("alpha"), false, false, false, None, &transport);
123
168
  let recorded = spawns.lock().unwrap().clone();
124
169
  assert_eq!(recorded.len(), 1, "exactly one respawn for alpha; got {recorded:?}");
@@ -150,10 +195,7 @@ fn reset_agent_discard_session_rebuilds_window_via_start_respawn() {
150
195
  let ws = restart_ws_two_resumable_workers(); // compiled spec + state(alpha,bravo running) + seeded coordinator
151
196
  let spawns = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
152
197
  // live session present (other workers keep it alive) but alpha's window was discarded.
153
- let transport = SessionProbeRecordingTransport {
154
- spawns: std::sync::Arc::clone(&spawns),
155
- session_exists: true,
156
- };
198
+ let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), spawn_targets: std::sync::Mutex::new(Vec::new()), session_exists: true, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
157
199
  let _ = reset_agent_with_transport(&ws, &AgentId::new("alpha"), true, false, None, &transport);
158
200
  let recorded = spawns.lock().unwrap().clone();
159
201
  assert!(
@@ -191,10 +233,7 @@ fn reset_agent_discard_session_syncs_projection_epoch_inputs_for_restart_agent_c
191
233
 
192
234
  let before = chrono::Utc::now();
193
235
  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
- };
236
+ let transport = SessionProbeRecordingTransport { spawns: std::sync::Arc::clone(&spawns), spawn_targets: std::sync::Mutex::new(Vec::new()), session_exists: true, killed_panes: std::sync::Mutex::new(std::collections::BTreeSet::new()) };
198
237
  let outcome = reset_agent_with_transport(&ws, &AgentId::new("alpha"), true, false, None, &transport)
199
238
  .expect("reset-agent alpha --discard-session --no-display");
200
239
  assert!(