@team-agent/installer 0.5.63 → 0.5.65

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 (37) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -0
  4. package/crates/team-agent/src/cli/emit.rs +15 -0
  5. package/crates/team-agent/src/cli/mod.rs +9 -8
  6. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/mod.rs +31 -0
  9. package/crates/team-agent/src/cli/tests/status_send.rs +13 -3
  10. package/crates/team-agent/src/cli/types.rs +7 -0
  11. package/crates/team-agent/src/coordinator/health.rs +17 -0
  12. package/crates/team-agent/src/coordinator/steps/abnormal.rs +55 -2
  13. package/crates/team-agent/src/db/migration.rs +2 -1
  14. package/crates/team-agent/src/db/schema.rs +16 -8
  15. package/crates/team-agent/src/leader/lease.rs +1 -2
  16. package/crates/team-agent/src/leader/start.rs +8 -8
  17. package/crates/team-agent/src/lifecycle/restart/agent.rs +51 -9
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -3
  19. package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -139
  20. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +148 -5
  21. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +45 -13
  22. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +10 -6
  23. package/crates/team-agent/src/lifecycle/tests.rs +11 -5
  24. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  25. package/crates/team-agent/src/messaging/delivery.rs +29 -0
  26. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +12 -0
  28. package/crates/team-agent/src/messaging/mod.rs +2 -0
  29. package/crates/team-agent/src/messaging/results.rs +5 -0
  30. package/crates/team-agent/src/messaging/send.rs +9 -0
  31. package/crates/team-agent/src/messaging/tests/runtime.rs +32 -0
  32. package/crates/team-agent/src/messaging/types.rs +2 -0
  33. package/crates/team-agent/src/messaging/wait.rs +366 -0
  34. package/crates/team-agent/src/messaging/watchers.rs +153 -6
  35. package/crates/team-agent/src/tmux_backend.rs +16 -12
  36. package/crates/team-agent/src/transport.rs +11 -11
  37. package/package.json +4 -4
@@ -5,8 +5,8 @@ use super::*;
5
5
  use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
6
6
 
7
7
  /// `remove_agent(workspace, agent_id, from_spec, force, team)`(`lifecycle/agents.py:22`)。
8
- /// 从 spec/state/team_state/role-file/agent_health 原子摘除;`_RemoveRollback` 字节级快照
9
- /// 回滚全部。未传 from_spec 确认 / 运行中未传 force → 拒绝。
8
+ /// 从 spec/state/team_state/agent_health 原子摘除;role markdown 是用户资产,始终保留。
9
+ /// `_RemoveRollback` 字节级快照回滚全部运行时变更。未传 from_spec 确认 / 运行中未传 force → 拒绝。
10
10
  pub fn remove_agent(
11
11
  workspace: &Path,
12
12
  agent_id: &AgentId,
@@ -567,9 +567,6 @@ fn remove_agent_inner(
567
567
  write_remove_step_event(paths.run_workspace, agent_id, "stop", &target, Some(true))?;
568
568
  }
569
569
  }
570
- let dynamic_role_path =
571
- managed_dynamic_role_file_path(paths.run_workspace, &working_state, agent_id)?;
572
- let dynamic_role_required = has_recorded_dynamic_role_file(&working_state, agent_id);
573
570
  // golden agents.py:81-83: removed_state = deepcopy(state); pop the agent; save_team_scoped_state
574
571
  // (team projection) — NOT a raw save, so other teams in a multi-team workspace are preserved.
575
572
  let mut removed_state = working_state;
@@ -623,22 +620,10 @@ fn remove_agent_inner(
623
620
  "team.spec.yaml",
624
621
  None,
625
622
  )?;
626
- let role_file_removed = match dynamic_role_path.as_deref() {
627
- Some(path) => remove_dynamic_role_file(path, dynamic_role_required)?,
628
- None => false,
629
- };
630
- if role_file_removed {
631
- let dynamic_role_path = dynamic_role_path.as_deref().expect("managed role path");
632
- let resource = dynamic_role_path.to_string_lossy().to_string();
633
- cleared_locations.push(serde_json::json!(resource));
634
- write_remove_step_event(
635
- paths.run_workspace,
636
- agent_id,
637
- "role_file",
638
- &dynamic_role_path.to_string_lossy(),
639
- None,
640
- )?;
641
- }
623
+ // Role markdown is user-owned input, including files under the registered
624
+ // dynamic-role path. Removing a seat only unregisters runtime state/spec;
625
+ // cleanup is intentionally not part of the default operation.
626
+ let role_file_removed = false;
642
627
  let agent_health_deleted = delete_agent_health(paths.run_workspace, team_key, agent_id)?;
643
628
  cleared_locations.push(serde_json::json!("agent_health"));
644
629
  write_remove_step_event(
@@ -976,92 +961,6 @@ fn task_without_agent_assignee(task: &YamlValue, agent_id: &AgentId) -> YamlValu
976
961
  )
977
962
  }
978
963
 
979
- fn remove_dynamic_role_file(path: &Path, required: bool) -> Result<bool, LifecycleError> {
980
- match std::fs::remove_file(path) {
981
- Ok(()) => Ok(true),
982
- Err(e) if e.kind() == std::io::ErrorKind::NotFound && required => Err(
983
- LifecycleError::StatePersist(format!("dynamic role file missing: {}", path.display())),
984
- ),
985
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
986
- Err(e) => Err(LifecycleError::StatePersist(format!(
987
- "remove role file {}: {e}",
988
- path.display()
989
- ))),
990
- }
991
- }
992
-
993
- fn dynamic_role_file_path(
994
- workspace: &Path,
995
- state: &serde_json::Value,
996
- agent_id: &AgentId,
997
- ) -> std::path::PathBuf {
998
- if let Some(raw) = state
999
- .get("agents")
1000
- .and_then(|v| v.get(agent_id.as_str()))
1001
- .and_then(|v| v.get("dynamic_role_file"))
1002
- .and_then(|v| v.as_str())
1003
- .filter(|s| !s.is_empty())
1004
- {
1005
- let path = std::path::PathBuf::from(raw);
1006
- if path.is_absolute() {
1007
- return path;
1008
- }
1009
- return workspace.join(path);
1010
- }
1011
- workspace
1012
- .join(".team")
1013
- .join("dynamic-role-files")
1014
- .join(format!("{}.md", agent_id.as_str()))
1015
- }
1016
-
1017
- /// Resolve a deletable role artifact. `dynamic_role_file` may point at an
1018
- /// external `--role-file`; only canonical children of the runtime-managed
1019
- /// directory belong to remove/rollback. A symlink escape is external.
1020
- fn managed_dynamic_role_file_path(
1021
- workspace: &Path,
1022
- state: &serde_json::Value,
1023
- agent_id: &AgentId,
1024
- ) -> Result<Option<std::path::PathBuf>, LifecycleError> {
1025
- let path = dynamic_role_file_path(workspace, state, agent_id);
1026
- let managed_root = workspace.join(".team").join("dynamic-role-files");
1027
- let canonical_root = match std::fs::canonicalize(&managed_root) {
1028
- Ok(path) => path,
1029
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1030
- return Ok(path.starts_with(&managed_root).then_some(path));
1031
- }
1032
- Err(error) => {
1033
- return Err(LifecycleError::StatePersist(format!(
1034
- "resolve managed role root {}: {error}",
1035
- managed_root.display()
1036
- )))
1037
- }
1038
- };
1039
- let canonical_path = match std::fs::canonicalize(&path) {
1040
- Ok(path) => path,
1041
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1042
- return Ok(path.starts_with(&managed_root).then_some(path));
1043
- }
1044
- Err(error) => {
1045
- return Err(LifecycleError::StatePersist(format!(
1046
- "resolve role file {}: {error}",
1047
- path.display()
1048
- )))
1049
- }
1050
- };
1051
- Ok(canonical_path
1052
- .starts_with(&canonical_root)
1053
- .then_some(canonical_path))
1054
- }
1055
-
1056
- fn has_recorded_dynamic_role_file(state: &serde_json::Value, agent_id: &AgentId) -> bool {
1057
- state
1058
- .get("agents")
1059
- .and_then(|v| v.get(agent_id.as_str()))
1060
- .and_then(|v| v.get("dynamic_role_file"))
1061
- .and_then(|v| v.as_str())
1062
- .is_some_and(|s| !s.is_empty())
1063
- }
1064
-
1065
964
  fn delete_agent_health(
1066
965
  workspace: &Path,
1067
966
  owner_team_id: &str,
@@ -1127,8 +1026,6 @@ struct RemoveRollback {
1127
1026
  state: serde_json::Value,
1128
1027
  team_state_text: Option<String>,
1129
1028
  team_state_path: std::path::PathBuf,
1130
- dynamic_role_bytes: Option<Vec<u8>>,
1131
- dynamic_role_path: Option<std::path::PathBuf>,
1132
1029
  /// golden agents.py:185: the agent_health row captured BEFORE delete, re-upserted on rollback.
1133
1030
  health: Option<CapturedHealth>,
1134
1031
  restore_running: bool,
@@ -1164,15 +1061,6 @@ impl RemoveRollback {
1164
1061
  )))
1165
1062
  }
1166
1063
  };
1167
- let dynamic_role_path = managed_dynamic_role_file_path(workspace, state, agent_id)?;
1168
- let dynamic_role_bytes = match dynamic_role_path.as_deref() {
1169
- Some(path) => match std::fs::read(path) {
1170
- Ok(bytes) => Some(bytes),
1171
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
1172
- Err(e) => return Err(LifecycleError::StatePersist(format!("read role file: {e}"))),
1173
- },
1174
- None => None,
1175
- };
1176
1064
  let health = select_agent_health(workspace, team_key, agent_id)?;
1177
1065
  Ok(Self {
1178
1066
  agent_id: agent_id.clone(),
@@ -1181,15 +1069,13 @@ impl RemoveRollback {
1181
1069
  state: state.clone(),
1182
1070
  team_state_text,
1183
1071
  team_state_path,
1184
- dynamic_role_bytes,
1185
- dynamic_role_path,
1186
1072
  health,
1187
1073
  restore_running: false,
1188
1074
  })
1189
1075
  }
1190
1076
 
1191
1077
  /// golden agents.py:189-227 `_RemoveRollback.restore`: BEST-EFFORT — wrap EACH artifact restore
1192
- /// (spec → workspace_state → team_state → role_file → agent_health) in its own try/except, append
1078
+ /// (spec → workspace_state → team_state → agent_health) in its own try/except, append
1193
1079
  /// per-artifact failures to `errors`, and NEVER short-circuit on the first failure. The worker is
1194
1080
  /// only re-started when restore_running AND no errors. Returns the collected error strings (empty
1195
1081
  /// == ok); the caller re-raises the ORIGINAL operation error annotated with rollback_ok.
@@ -1236,24 +1122,6 @@ impl RemoveRollback {
1236
1122
  if let Err(e) = team_state_result {
1237
1123
  errors.push(format!("team_state:{e}"));
1238
1124
  }
1239
- // role_file
1240
- let role_file_result = match (&self.dynamic_role_path, &self.dynamic_role_bytes) {
1241
- (Some(path), Some(bytes)) => {
1242
- if let Some(parent) = path.parent() {
1243
- let _ = std::fs::create_dir_all(parent);
1244
- }
1245
- std::fs::write(path, bytes)
1246
- }
1247
- (Some(path), None) => match std::fs::remove_file(path) {
1248
- Ok(()) => Ok(()),
1249
- Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1250
- Err(e) => Err(e),
1251
- },
1252
- (None, _) => Ok(()),
1253
- };
1254
- if let Err(e) = role_file_result {
1255
- errors.push(format!("role_file:{e}"));
1256
- }
1257
1125
  if self.restore_running && errors.is_empty() {
1258
1126
  if let Err(e) = start_agent_at_paths(
1259
1127
  workspace,
@@ -1,6 +1,6 @@
1
1
  use super::launch_spawn::{
2
- quick_start_team_dir, seed_healthy_coordinator, DELEG_ROLE_ALPHA, DELEG_ROLE_BRAVO,
3
- QS_VALID_ROLE,
2
+ quick_start_team_dir, restart_ws_two_resumable_workers, seed_healthy_coordinator,
3
+ DELEG_ROLE_ALPHA, DELEG_ROLE_BRAVO, QS_VALID_ROLE,
4
4
  };
5
5
  use super::*;
6
6
  use crate::transport::test_support::OfflineTransport;
@@ -52,6 +52,149 @@ pub(super) fn lanea_team_ws(status: &str) -> PathBuf {
52
52
  ws
53
53
  }
54
54
 
55
+ // A-31 RED: a stale pane must not be treated as a live seat merely because its
56
+ // old window name still exists. The assertion is on the physical spawn record,
57
+ // not on start-agent's success-shaped return value.
58
+ #[test]
59
+ fn start_agent_pane_dead_must_not_noop_on_live_window() {
60
+ let ws = restart_ws_two_resumable_workers();
61
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
62
+ state["agents"]["alpha"]["pane_id"] = json!("%dead");
63
+ state["agents"]["alpha"]["stale"] = json!(true);
64
+ state["agents"]["alpha"]["stale_reason"] = json!("pane_dead");
65
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
66
+
67
+ let transport = OfflineTransport::new()
68
+ .with_session_present(true)
69
+ .with_windows(vec![crate::transport::WindowName::new("alpha")])
70
+ .with_pane_presence("%dead", false)
71
+ .with_liveness("%dead", crate::transport::PaneLiveness::Dead);
72
+ let _ = start_agent_with_transport(
73
+ &ws,
74
+ &aid("alpha"),
75
+ false,
76
+ false,
77
+ false,
78
+ None,
79
+ &transport,
80
+ );
81
+
82
+ assert!(
83
+ !transport.spawn_records().is_empty(),
84
+ "A-31: pane_dead must trigger a physical respawn even when the stale window remains; asserting only the command's success-shaped return would miss the Noop bug"
85
+ );
86
+ }
87
+
88
+ // A-31 RED: a live pane from another session is not this agent's live seat.
89
+ // The assertion is on the physical spawn record, not on start-agent's
90
+ // success-shaped return value.
91
+ #[test]
92
+ fn start_agent_foreign_live_pane_must_respawn_for_intended_window() {
93
+ let ws = restart_ws_two_resumable_workers();
94
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
95
+ state["agents"]["alpha"]["pane_id"] = json!("%foreign");
96
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
97
+
98
+ let foreign = crate::transport::PaneInfo {
99
+ pane_id: crate::transport::PaneId::new("%foreign"),
100
+ session: crate::transport::SessionName::new("foreign-session"),
101
+ window_index: None,
102
+ window_name: Some(crate::transport::WindowName::new("foreign-window")),
103
+ pane_index: None,
104
+ tty: None,
105
+ current_command: None,
106
+ current_path: None,
107
+ active: true,
108
+ pane_pid: None,
109
+ leader_env: std::collections::BTreeMap::new(),
110
+ };
111
+ let transport = OfflineTransport::new()
112
+ .with_session_present(true)
113
+ .with_windows(vec![crate::transport::WindowName::new("alpha")])
114
+ .with_targets(vec![foreign])
115
+ .with_pane_presence("%foreign", true)
116
+ .with_liveness("%foreign", crate::transport::PaneLiveness::Live);
117
+ let _ = start_agent_with_transport(
118
+ &ws,
119
+ &aid("alpha"),
120
+ false,
121
+ false,
122
+ false,
123
+ None,
124
+ &transport,
125
+ );
126
+
127
+ assert!(
128
+ !transport.spawn_records().is_empty(),
129
+ "A-31: a physically live pane owned by another session must trigger a physical respawn for alpha; start-agent must not Noop on foreign pane %foreign"
130
+ );
131
+ }
132
+
133
+ // A-35 mirror RED: an ownership query failure is missing evidence, not proof
134
+ // that the cached live pane is foreign. start-agent must retain the base
135
+ // conservative Noop direction and leave retry authority to the operator.
136
+ #[test]
137
+ fn start_agent_transport_ownership_query_failure_must_not_respawn_live_pane() {
138
+ let ws = restart_ws_two_resumable_workers();
139
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
140
+ state["agents"]["alpha"]["pane_id"] = json!("%transport-error");
141
+ state["agents"]["alpha"]["stale"] = json!(false);
142
+ state["agents"]["alpha"]["stale_reason"] = serde_json::Value::Null;
143
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
144
+
145
+ let transport = OfflineTransport::new()
146
+ .with_session_present(true)
147
+ .with_windows(vec![crate::transport::WindowName::new("alpha")])
148
+ .with_pane_presence("%transport-error", true)
149
+ .with_liveness("%transport-error", crate::transport::PaneLiveness::Live)
150
+ .with_list_targets_error("simulated tmux snapshot failure");
151
+ let _ = start_agent_with_transport(
152
+ &ws,
153
+ &aid("alpha"),
154
+ false,
155
+ false,
156
+ false,
157
+ None,
158
+ &transport,
159
+ );
160
+
161
+ assert!(
162
+ transport.spawn_records().is_empty(),
163
+ "A-35 mirror: list_targets failure is missing ownership evidence, not proof of a foreign/dead pane; a physically live cached pane must not trigger a duplicate spawn"
164
+ );
165
+ }
166
+
167
+ // A-31 guard RED: an explicit pane_dead reason remains positive death
168
+ // evidence even when the persisted pane tuple is absent.
169
+ #[test]
170
+ fn start_agent_pane_dead_reason_must_respawn_without_cached_pane_id() {
171
+ let ws = restart_ws_two_resumable_workers();
172
+ let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
173
+ let agent = state["agents"]["alpha"].as_object_mut().unwrap();
174
+ agent.remove("pane_id");
175
+ agent.insert("stale".to_string(), json!(true));
176
+ agent.insert("stale_reason".to_string(), json!("pane_dead"));
177
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
178
+
179
+ let transport = OfflineTransport::new()
180
+ .with_session_present(true)
181
+ .with_windows(vec![crate::transport::WindowName::new("alpha")]);
182
+ let _ = start_agent_with_transport(
183
+ &ws,
184
+ &aid("alpha"),
185
+ false,
186
+ false,
187
+ false,
188
+ None,
189
+ &transport,
190
+ );
191
+
192
+ assert!(
193
+ !transport.spawn_records().is_empty(),
194
+ "A-31 guard: stale_reason=pane_dead is positive death evidence and must trigger a physical respawn even when no cached pane_id remains"
195
+ );
196
+ }
197
+
55
198
  // remove_agent [P0] — from_spec + force on a NON-running agent atomically removes it from state.agents
56
199
  // (golden agents.py: pop agents[agent_id] + save). Pure fs/state (non-running -> no stop/tmux). Today the
57
200
  // stub returns OwnerRefused and removes nothing -> RED.
@@ -375,9 +518,9 @@ if [ "$track" != 1 ]; then
375
518
  fi
376
519
  # 0.5.39 Slice 2: worker spawn now goes through the worker shell
377
520
  # wrapper (tmux-server-death-locate §7 Slice 2), which embeds a
378
- # printf format literal containing "\n" bytes so the pane returns to
379
- # an interactive shell with an explicit exit marker instead of
380
- # collapsing to `[exited]`. Those embedded newlines land inside the
521
+ # printf format literal containing "\n" bytes so the pane retains an
522
+ # inert sh tail with an explicit exit marker instead of collapsing to
523
+ # `[exited]`. Those embedded newlines land inside the
381
524
  # tmux argv payload, so a naive `printf '%s\n' "$*"` writes multiple
382
525
  # lines per tmux invocation and downstream `raw.lines()` scans see
383
526
  # fake "argv" lines that carry only the marker text. Escape newlines
@@ -1390,6 +1390,45 @@ fn remove_preserves_external_role_source_for_both_flag_forms() {
1390
1390
  }
1391
1391
  }
1392
1392
 
1393
+ // A-28 RED: the role markdown recorded for a seat is still a user asset. The
1394
+ // default remove path must unregister the seat without deleting the managed
1395
+ // registration-path document.
1396
+ #[test]
1397
+ fn remove_agent_preserves_registered_role_markdown_by_default() {
1398
+ let ws = lanea_ws_agents(json!({
1399
+ "alpha": {
1400
+ "status": "stopped",
1401
+ "provider": "codex",
1402
+ "window": "alpha",
1403
+ "dynamic_role_file": ".team/dynamic-role-files/alpha.md"
1404
+ },
1405
+ "bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
1406
+ }));
1407
+ let role = ws.join(".team/dynamic-role-files/alpha.md");
1408
+ std::fs::create_dir_all(role.parent().unwrap()).unwrap();
1409
+ let original = b"user-authored registered role\n";
1410
+ std::fs::write(&role, original).unwrap();
1411
+
1412
+ let result = remove_agent_with_transport(
1413
+ &ws,
1414
+ &aid("alpha"),
1415
+ false,
1416
+ true,
1417
+ None,
1418
+ &LaneTransport::new("team-laneateam", &[]),
1419
+ );
1420
+ assert!(result.is_ok(), "remove-agent should unregister the seat: {result:?}");
1421
+ assert!(
1422
+ role.exists(),
1423
+ "A-28: remove-agent must preserve the registered role markdown by default"
1424
+ );
1425
+ assert_eq!(
1426
+ std::fs::read(&role).unwrap(),
1427
+ original,
1428
+ "A-28: remove-agent must preserve the registered role markdown by default"
1429
+ );
1430
+ }
1431
+
1393
1432
  #[cfg(unix)]
1394
1433
  #[test]
1395
1434
  fn remove_preserves_managed_path_symlink_escape() {
@@ -1564,26 +1603,19 @@ fn lanea_remove_rollback_restarts_force_stopped_worker() {
1564
1603
  );
1565
1604
  }
1566
1605
 
1567
- // ── REMOVE #11 (remove-dynamic-role-path-and-required-8, warn) [RED] missing REQUIRED role file raises
1568
- // Golden agents.py:255-261 _remove_dynamic_role_file(path, required=True) RAISES "dynamic role file
1569
- // missing: <path>" when the state recorded a dynamic_role_file but it is absent. Rust hardcodes the
1570
- // default path and returns Ok(false) silently (restart.rs:951-953), losing the hard-fail+rollback. RED:
1571
- // a dynamic agent whose recorded role file is MISSING must raise, not silently complete the removal.
1606
+ // A recorded role path is user-owned input, not a remove-agent precondition.
1607
+ // Missing role markdown therefore must not block unregistering the seat.
1572
1608
  #[test]
1573
- fn lanea_remove_dynamic_role_file_missing_raises() {
1609
+ fn lanea_remove_dynamic_role_file_missing_does_not_block_unregister() {
1574
1610
  let ws = lanea_ws_agents(json!({
1575
1611
  "alpha": { "status": "stopped", "provider": "codex", "window": "alpha", "dynamic_role_file": ".team/dynamic-role-files/custom.md" }, // file NOT created
1576
1612
  "bravo": { "status": "stopped", "provider": "codex", "window": "bravo" }
1577
1613
  }));
1578
1614
  let tx = LaneTransport::new("team-laneateam", &[]);
1579
- let text = format!(
1580
- "{:?}",
1581
- remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx)
1582
- );
1615
+ let result = remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx);
1583
1616
  assert!(
1584
- text.contains("dynamic role file missing"),
1585
- "golden agents.py:259-260: a state-recorded dynamic_role_file that is MISSING must RAISE 'dynamic role \
1586
- file missing: <path>' (required=true); Rust returns Ok(false) silently and completes the remove. got {text}"
1617
+ result.is_ok(),
1618
+ "remove-agent must unregister even when a recorded role path is missing; got {result:?}"
1587
1619
  );
1588
1620
  }
1589
1621
 
@@ -167,13 +167,17 @@ fn restart_failure_aggregation_matches_serial_semantics_without_early_abort() {
167
167
  ["w1", "w2", "w4", "w5", "w6", "w7", "w8"],
168
168
  "R2: successful workers after w3 must still be spawned and reported"
169
169
  );
170
+ let mut observed_spawn_windows = transport
171
+ .spawn_calls()
172
+ .into_iter()
173
+ .map(|call| call.window)
174
+ .collect::<Vec<_>>();
175
+ let mut expected_spawn_windows = worker_ids(8);
176
+ observed_spawn_windows.sort();
177
+ expected_spawn_windows.sort();
170
178
  assert_eq!(
171
- transport
172
- .spawn_calls()
173
- .iter()
174
- .map(|call| call.window.as_str())
175
- .collect::<Vec<_>>(),
176
- worker_ids(8),
179
+ observed_spawn_windows,
180
+ expected_spawn_windows,
177
181
  "R2: failure aggregation must attempt the full plan; no early return after w3"
178
182
  );
179
183
 
@@ -24,17 +24,23 @@ fn sess(s: &str) -> SessionName {
24
24
  pub(crate) fn test_binary_path() -> &'static str {
25
25
  static PATH: OnceLock<String> = OnceLock::new();
26
26
  PATH.get_or_init(|| {
27
- if let Ok(path) = std::env::var("CARGO_BIN_EXE_team-agent") {
28
- return path;
29
- }
30
- let current = std::env::current_exe().expect("test executable path");
31
- current
27
+ let path = if let Ok(path) = std::env::var("CARGO_BIN_EXE_team-agent") {
28
+ path
29
+ } else {
30
+ let current = std::env::current_exe().expect("test executable path");
31
+ current
32
32
  .parent()
33
33
  .and_then(|deps| deps.parent())
34
34
  .map(|target| target.join("team-agent"))
35
35
  .expect("team-agent test binary path")
36
36
  .to_string_lossy()
37
37
  .into_owned()
38
+ };
39
+ assert!(
40
+ std::path::Path::new(&path).is_file(),
41
+ "team-agent test binary does not exist: {path}; run `cargo build -p team-agent --bin team-agent` first"
42
+ );
43
+ path
38
44
  })
39
45
  .as_str()
40
46
  }
@@ -211,6 +211,7 @@ pub(crate) fn delivery_outcome_value(out: &DeliveryOutcome) -> Value {
211
211
  "ok": out.ok,
212
212
  "status": enum_value(out.status),
213
213
  "message_id": out.message_id,
214
+ "ack_forced_off": out.ack_forced_off,
214
215
  });
215
216
  if let Some(obj) = value.as_object_mut() {
216
217
  if let Some(reason) = out.reason {