@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +5 -0
- package/crates/team-agent/src/cli/emit.rs +15 -0
- package/crates/team-agent/src/cli/mod.rs +9 -8
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/tests/mod.rs +31 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +13 -3
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/health.rs +17 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +55 -2
- package/crates/team-agent/src/db/migration.rs +2 -1
- package/crates/team-agent/src/db/schema.rs +16 -8
- package/crates/team-agent/src/leader/lease.rs +1 -2
- package/crates/team-agent/src/leader/start.rs +8 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +51 -9
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -3
- package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -139
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +148 -5
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +45 -13
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +10 -6
- package/crates/team-agent/src/lifecycle/tests.rs +11 -5
- package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/delivery.rs +29 -0
- package/crates/team-agent/src/messaging/helpers.rs +2 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +12 -0
- package/crates/team-agent/src/messaging/mod.rs +2 -0
- package/crates/team-agent/src/messaging/results.rs +5 -0
- package/crates/team-agent/src/messaging/send.rs +9 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +32 -0
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/wait.rs +366 -0
- package/crates/team-agent/src/messaging/watchers.rs +153 -6
- package/crates/team-agent/src/tmux_backend.rs +16 -12
- package/crates/team-agent/src/transport.rs +11 -11
- 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/
|
|
9
|
-
///
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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 →
|
|
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,
|
|
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
|
|
379
|
-
#
|
|
380
|
-
#
|
|
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
|
-
//
|
|
1568
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
1585
|
-
"
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
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 {
|