@team-agent/installer 0.5.63 → 0.5.64

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.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.63"
578
+ version = "0.5.64"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.63"
12
+ version = "0.5.64"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -131,8 +131,35 @@ pub(crate) fn start_agent_at_paths(
131
131
  return Err(LifecycleError::RequirementUnmet(error));
132
132
  }
133
133
  }
134
- let agent_live = if adaptive_layout {
135
- agent_pane_live(transport, &raw_agent)
134
+ // A persisted window name is only a topology hint. Once a pane binding is
135
+ // recorded, the pane must be both physically live and owned by the
136
+ // intended session/window; a foreign pane must never make start-agent
137
+ // return Noop. A coordinator-provided pane_dead reason is also
138
+ // authoritative when a backend cannot answer the exact-pane probe.
139
+ let pane_marked_dead = raw_agent
140
+ .get("stale_reason")
141
+ .and_then(serde_json::Value::as_str)
142
+ .is_some_and(|reason| matches!(reason, "pane_dead" | "both"));
143
+ // A stale cached pane may still be recoverable without a spawn when the
144
+ // intended per-agent window has exactly one live pane. The pane found by
145
+ // this session/window lookup, not the cached id, is the binding that the
146
+ // Noop path refreshes. A foreign pane with no intended window still falls
147
+ // through to a real spawn.
148
+ let noop_pane = if adaptive_layout {
149
+ None
150
+ } else {
151
+ single_live_pane_for_window(transport, &session_name, &window)
152
+ };
153
+ let agent_live = if pane_marked_dead {
154
+ false
155
+ } else if adaptive_layout
156
+ || raw_agent
157
+ .get("pane_id")
158
+ .and_then(serde_json::Value::as_str)
159
+ .is_some_and(|pane| !pane.is_empty())
160
+ {
161
+ agent_pane_owned_and_live(transport, &raw_agent, &session_name, &window)
162
+ || noop_pane.is_some()
136
163
  } else {
137
164
  window_exists(transport, &session_name, &window)
138
165
  };
@@ -145,11 +172,6 @@ pub(crate) fn start_agent_at_paths(
145
172
  // state — assert_topology_invariants from Step 1 catches the
146
173
  // upstream corruption.
147
174
  let has_collision = pane_conflicts_with_leader_or_other(&state, agent_id, &raw_agent);
148
- let noop_pane = if adaptive_layout {
149
- None
150
- } else {
151
- single_live_pane_for_window(transport, &session_name, &window)
152
- };
153
175
  if has_collision && noop_pane.is_none() {
154
176
  eprintln!(
155
177
  "team_agent::layout e51_collision_post_step2 agent_id=`{agent_id}` \
@@ -603,7 +625,12 @@ fn pane_socket_binding(value: &serde_json::Value) -> Option<PaneSocketBinding<'_
603
625
  })
604
626
  }
605
627
 
606
- fn agent_pane_live(transport: &dyn crate::transport::Transport, agent: &serde_json::Value) -> bool {
628
+ fn agent_pane_owned_and_live(
629
+ transport: &dyn crate::transport::Transport,
630
+ agent: &serde_json::Value,
631
+ expected_session: &crate::transport::SessionName,
632
+ expected_window: &str,
633
+ ) -> bool {
607
634
  let Some(pane) = agent
608
635
  .get("pane_id")
609
636
  .and_then(serde_json::Value::as_str)
@@ -612,7 +639,22 @@ fn agent_pane_live(transport: &dyn crate::transport::Transport, agent: &serde_js
612
639
  else {
613
640
  return false;
614
641
  };
615
- agent_pane_live_by_id(transport, &pane)
642
+ let Ok(targets) = transport.list_targets() else {
643
+ // Ownership is unknown when the topology snapshot fails. Preserve the
644
+ // base liveness direction: only positive death evidence may authorize
645
+ // a destructive respawn; otherwise fall back to the pane probe and
646
+ // leave retry authority to the operator.
647
+ return agent_pane_live_by_id(transport, &pane);
648
+ };
649
+ let owned = targets.iter().any(|target| {
650
+ target.pane_id == pane
651
+ && target.session.as_str() == expected_session.as_str()
652
+ && target
653
+ .window_name
654
+ .as_ref()
655
+ .is_some_and(|window| window.as_str() == expected_window)
656
+ });
657
+ owned && agent_pane_live_by_id(transport, &pane)
616
658
  }
617
659
 
618
660
  fn agent_pane_live_by_id(
@@ -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.
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.63",
3
+ "version": "0.5.64",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.63",
24
- "@team-agent/cli-darwin-x64": "0.5.63",
25
- "@team-agent/cli-linux-x64": "0.5.63"
23
+ "@team-agent/cli-darwin-arm64": "0.5.64",
24
+ "@team-agent/cli-darwin-x64": "0.5.64",
25
+ "@team-agent/cli-linux-x64": "0.5.64"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",