@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +51 -9
- package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -139
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +145 -2
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +45 -13
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -131,8 +131,35 @@ pub(crate) fn start_agent_at_paths(
|
|
|
131
131
|
return Err(LifecycleError::RequirementUnmet(error));
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
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.
|
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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",
|