@team-agent/installer 0.5.51 → 0.5.52
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/named_address.rs +35 -67
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/leader/lease.rs +202 -16
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +7 -1
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +19 -3
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +100 -1
- package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
- package/crates/team-agent/src/lifecycle/restart.rs +25 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +331 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +12 -0
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/messaging/delivery.rs +126 -147
- package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
- package/crates/team-agent/src/messaging/mod.rs +5 -0
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +282 -20
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository.rs +36 -12
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/package.json +4 -4
|
@@ -3119,10 +3119,16 @@ fn restart_candidate_from_state(
|
|
|
3119
3119
|
.filter(|s| !s.is_empty())
|
|
3120
3120
|
.map(SessionName::new)
|
|
3121
3121
|
.unwrap_or_else(|| SessionName::new(team_name));
|
|
3122
|
+
let retired = retired_agent_ids(state);
|
|
3122
3123
|
let mut agents = state
|
|
3123
3124
|
.get("agents")
|
|
3124
3125
|
.and_then(serde_json::Value::as_object)
|
|
3125
|
-
.map(|map|
|
|
3126
|
+
.map(|map| {
|
|
3127
|
+
map.keys()
|
|
3128
|
+
.filter(|agent_id| !retired.contains(agent_id.as_str()))
|
|
3129
|
+
.map(AgentId::new)
|
|
3130
|
+
.collect::<Vec<_>>()
|
|
3131
|
+
})
|
|
3126
3132
|
.unwrap_or_default();
|
|
3127
3133
|
agents.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
|
3128
3134
|
RestartCandidate {
|
|
@@ -3135,6 +3141,19 @@ fn restart_candidate_from_state(
|
|
|
3135
3141
|
}
|
|
3136
3142
|
}
|
|
3137
3143
|
|
|
3144
|
+
fn retired_agent_ids(state: &serde_json::Value) -> std::collections::BTreeSet<String> {
|
|
3145
|
+
state
|
|
3146
|
+
.get("agent_lifecycle")
|
|
3147
|
+
.and_then(serde_json::Value::as_object)
|
|
3148
|
+
.into_iter()
|
|
3149
|
+
.flat_map(|lifecycle| lifecycle.iter())
|
|
3150
|
+
.filter(|(_, entry)| {
|
|
3151
|
+
entry.get("state").and_then(serde_json::Value::as_str) == Some("retired")
|
|
3152
|
+
})
|
|
3153
|
+
.map(|(agent_id, _)| agent_id.clone())
|
|
3154
|
+
.collect()
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3138
3157
|
/// E5 task#3 / RC-A6a:每次 restart 都以**角色定义**(team_dir 的 TEAM.md+agents/*.md)
|
|
3139
3158
|
/// compile_team 重建 runtime spec(覆盖),保留运行期 override(session_name 必须延续,
|
|
3140
3159
|
/// 否则 tmux session 对不上)。写到 .team/runtime/<team_key>/team.spec.yaml。
|
|
@@ -3204,6 +3223,7 @@ fn rebuild_runtime_spec_from_roles(
|
|
|
3204
3223
|
// 静态 team_dir/agents/*.md + state 记录的 dynamic role source。缺文件 fail-closed
|
|
3205
3224
|
// 三行式,不静默 prune 已 live 的 helper(persist SaveConflict 保护继续生效)。
|
|
3206
3225
|
merge_state_dynamic_role_files(&mut spec, run_workspace, &team_dir, team_key, state)?;
|
|
3226
|
+
filter_retired_agents_from_spec(&mut spec, &retired_agent_ids(state));
|
|
3207
3227
|
// 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
|
|
3208
3228
|
let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
|
|
3209
3229
|
crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
|
|
@@ -3216,6 +3236,15 @@ fn rebuild_runtime_spec_from_roles(
|
|
|
3216
3236
|
Ok(spec)
|
|
3217
3237
|
}
|
|
3218
3238
|
|
|
3239
|
+
fn filter_retired_agents_from_spec(
|
|
3240
|
+
spec: &mut YamlValue,
|
|
3241
|
+
retired: &std::collections::BTreeSet<String>,
|
|
3242
|
+
) {
|
|
3243
|
+
for agent_id in retired {
|
|
3244
|
+
*spec = super::remove::spec_without_agent(spec, &AgentId::new(agent_id));
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3219
3248
|
/// 0.5.30 (`add-agent-restart-saveconflict-locate.md` §4/§11): 把 add-agent
|
|
3220
3249
|
/// 写入 `state.agents.<id>.dynamic_role_file` 的动态 role 文档合并回 restart
|
|
3221
3250
|
/// 重建 spec。规则:
|
|
@@ -3418,6 +3447,76 @@ mod tests {
|
|
|
3418
3447
|
TransportError,
|
|
3419
3448
|
};
|
|
3420
3449
|
|
|
3450
|
+
#[test]
|
|
3451
|
+
fn retired_filter_clears_every_spec_reference_via_remove_primitive() {
|
|
3452
|
+
let mut spec = crate::model::yaml::loads(
|
|
3453
|
+
r#"
|
|
3454
|
+
name: retirement-filter
|
|
3455
|
+
agents:
|
|
3456
|
+
- id: gone
|
|
3457
|
+
- id: keep
|
|
3458
|
+
runtime:
|
|
3459
|
+
startup_order:
|
|
3460
|
+
- gone
|
|
3461
|
+
- keep
|
|
3462
|
+
routing:
|
|
3463
|
+
default_assignee: gone
|
|
3464
|
+
rules:
|
|
3465
|
+
- match: gone-task
|
|
3466
|
+
assign_to: gone
|
|
3467
|
+
- match: keep-task
|
|
3468
|
+
assign_to: keep
|
|
3469
|
+
tasks:
|
|
3470
|
+
- title: gone-task
|
|
3471
|
+
assignee: gone
|
|
3472
|
+
- title: keep-task
|
|
3473
|
+
assignee: keep
|
|
3474
|
+
"#,
|
|
3475
|
+
)
|
|
3476
|
+
.expect("fixture spec");
|
|
3477
|
+
let retired = std::collections::BTreeSet::from(["gone".to_string()]);
|
|
3478
|
+
|
|
3479
|
+
filter_retired_agents_from_spec(&mut spec, &retired);
|
|
3480
|
+
|
|
3481
|
+
let agents = spec
|
|
3482
|
+
.get("agents")
|
|
3483
|
+
.and_then(YamlValue::as_list)
|
|
3484
|
+
.expect("agents");
|
|
3485
|
+
assert!(agents.iter().all(|agent| {
|
|
3486
|
+
agent.get("id").and_then(YamlValue::as_str) != Some("gone")
|
|
3487
|
+
}));
|
|
3488
|
+
let startup_order = spec
|
|
3489
|
+
.get("runtime")
|
|
3490
|
+
.and_then(|runtime| runtime.get("startup_order"))
|
|
3491
|
+
.and_then(YamlValue::as_list)
|
|
3492
|
+
.expect("startup_order");
|
|
3493
|
+
assert!(startup_order
|
|
3494
|
+
.iter()
|
|
3495
|
+
.all(|agent_id| agent_id.as_str() != Some("gone")));
|
|
3496
|
+
let routing = spec.get("routing").expect("routing");
|
|
3497
|
+
assert_ne!(
|
|
3498
|
+
routing
|
|
3499
|
+
.get("default_assignee")
|
|
3500
|
+
.and_then(YamlValue::as_str),
|
|
3501
|
+
Some("gone"),
|
|
3502
|
+
"spec_default_assignee must not return a retired agent"
|
|
3503
|
+
);
|
|
3504
|
+
let rules = routing
|
|
3505
|
+
.get("rules")
|
|
3506
|
+
.and_then(YamlValue::as_list)
|
|
3507
|
+
.expect("routing rules");
|
|
3508
|
+
assert!(rules.iter().all(|rule| {
|
|
3509
|
+
rule.get("assign_to").and_then(YamlValue::as_str) != Some("gone")
|
|
3510
|
+
}));
|
|
3511
|
+
let tasks = spec
|
|
3512
|
+
.get("tasks")
|
|
3513
|
+
.and_then(YamlValue::as_list)
|
|
3514
|
+
.expect("tasks");
|
|
3515
|
+
assert!(tasks.iter().all(|task| {
|
|
3516
|
+
task.get("assignee").and_then(YamlValue::as_str) != Some("gone")
|
|
3517
|
+
}));
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3421
3520
|
#[test]
|
|
3422
3521
|
fn resume_decision_event_uses_central_scrub_and_preserves_required_failure() {
|
|
3423
3522
|
let marker = "synthetic-resume-decision-marker";
|
|
@@ -15,13 +15,15 @@ pub fn remove_agent(
|
|
|
15
15
|
team: Option<&str>,
|
|
16
16
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
17
17
|
let paths = lifecycle_paths(workspace, team)?;
|
|
18
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
19
|
+
let team = canonical_team.as_deref();
|
|
18
20
|
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
19
21
|
workspace: &paths.run_workspace,
|
|
20
22
|
operation: "remove-agent",
|
|
21
23
|
team,
|
|
22
24
|
agent_id: Some(agent_id),
|
|
23
25
|
})?;
|
|
24
|
-
let transport =
|
|
26
|
+
let transport = paths.tmux_backend()?;
|
|
25
27
|
remove_agent_at_paths(
|
|
26
28
|
&paths.run_workspace,
|
|
27
29
|
&paths.spec_workspace,
|
|
@@ -42,6 +44,8 @@ pub fn remove_agent_with_transport(
|
|
|
42
44
|
transport: &dyn crate::transport::Transport,
|
|
43
45
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
44
46
|
let paths = lifecycle_paths(workspace, team)?;
|
|
47
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
48
|
+
let team = canonical_team.as_deref();
|
|
45
49
|
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
46
50
|
workspace: &paths.run_workspace,
|
|
47
51
|
operation: "remove-agent",
|
|
@@ -68,6 +72,8 @@ pub(crate) fn remove_agent_with_transport_locked(
|
|
|
68
72
|
transport: &dyn crate::transport::Transport,
|
|
69
73
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
70
74
|
let paths = lifecycle_paths(workspace, team)?;
|
|
75
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
76
|
+
let team = canonical_team.as_deref();
|
|
71
77
|
remove_agent_at_paths(
|
|
72
78
|
&paths.run_workspace,
|
|
73
79
|
&paths.spec_workspace,
|
|
@@ -94,6 +100,8 @@ impl ForceRecreateSnapshot {
|
|
|
94
100
|
transport: &dyn crate::transport::Transport,
|
|
95
101
|
) -> Result<Self, LifecycleError> {
|
|
96
102
|
let paths = lifecycle_paths(workspace, team)?;
|
|
103
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
104
|
+
let team = canonical_team.as_deref();
|
|
97
105
|
let seat = resolve_seat(
|
|
98
106
|
&paths.run_workspace,
|
|
99
107
|
&paths.spec_workspace,
|
|
@@ -215,7 +223,9 @@ pub fn remove_agent_flag_requirements(
|
|
|
215
223
|
team: Option<&str>,
|
|
216
224
|
) -> Result<RemoveAgentFlagRequirements, LifecycleError> {
|
|
217
225
|
let paths = lifecycle_paths(workspace, team)?;
|
|
218
|
-
let
|
|
226
|
+
let canonical_team = paths.canonical_team(team).map(str::to_string);
|
|
227
|
+
let team = canonical_team.as_deref();
|
|
228
|
+
let transport = paths.tmux_backend()?;
|
|
219
229
|
Ok(remove_agent_preflight(
|
|
220
230
|
&paths.run_workspace,
|
|
221
231
|
&paths.spec_workspace,
|
|
@@ -564,14 +574,16 @@ fn remove_agent_inner(
|
|
|
564
574
|
// (team projection) — NOT a raw save, so other teams in a multi-team workspace are preserved.
|
|
565
575
|
let mut removed_state = working_state;
|
|
566
576
|
remove_agent_from_state(&mut removed_state, agent_id)?;
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
577
|
+
mark_agent_retired_in_state(&mut removed_state, agent_id)?;
|
|
578
|
+
crate::state::repository::StateRepository::new(paths.run_workspace)
|
|
579
|
+
.save(
|
|
580
|
+
crate::state::repository::StateWriteIntent::RemoveAgent {
|
|
581
|
+
team_key,
|
|
582
|
+
agent_id: agent_id.as_str(),
|
|
583
|
+
},
|
|
584
|
+
&removed_state,
|
|
585
|
+
)
|
|
586
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
575
587
|
cleared_locations.push(serde_json::json!("state.json:agents"));
|
|
576
588
|
write_remove_step_event(
|
|
577
589
|
paths.run_workspace,
|
|
@@ -783,6 +795,57 @@ fn remove_agent_from_state(
|
|
|
783
795
|
}
|
|
784
796
|
}
|
|
785
797
|
|
|
798
|
+
pub(crate) fn mark_agent_retired_in_state(
|
|
799
|
+
state: &mut serde_json::Value,
|
|
800
|
+
agent_id: &AgentId,
|
|
801
|
+
) -> Result<(), LifecycleError> {
|
|
802
|
+
let root = state.as_object_mut().ok_or_else(|| {
|
|
803
|
+
LifecycleError::StatePersist("runtime state root is not an object".to_string())
|
|
804
|
+
})?;
|
|
805
|
+
let lifecycle = root
|
|
806
|
+
.entry("agent_lifecycle".to_string())
|
|
807
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
808
|
+
let lifecycle = lifecycle.as_object_mut().ok_or_else(|| {
|
|
809
|
+
LifecycleError::StatePersist("runtime state agent_lifecycle is not an object".to_string())
|
|
810
|
+
})?;
|
|
811
|
+
let entry = lifecycle
|
|
812
|
+
.entry(agent_id.as_str().to_string())
|
|
813
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
814
|
+
if entry.get("state").and_then(serde_json::Value::as_str) == Some("retired") {
|
|
815
|
+
return Ok(());
|
|
816
|
+
}
|
|
817
|
+
let entry = entry.as_object_mut().ok_or_else(|| {
|
|
818
|
+
LifecycleError::StatePersist(format!(
|
|
819
|
+
"runtime state agent_lifecycle.{} is not an object",
|
|
820
|
+
agent_id.as_str()
|
|
821
|
+
))
|
|
822
|
+
})?;
|
|
823
|
+
entry.insert("state".to_string(), serde_json::json!("retired"));
|
|
824
|
+
entry.insert(
|
|
825
|
+
"changed_at".to_string(),
|
|
826
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
827
|
+
);
|
|
828
|
+
entry.insert("reason".to_string(), serde_json::json!("remove-agent"));
|
|
829
|
+
Ok(())
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
pub(crate) fn clear_agent_retirement_in_state(state: &mut serde_json::Value, agent_id: &AgentId) {
|
|
833
|
+
let Some(lifecycle) = state
|
|
834
|
+
.get_mut("agent_lifecycle")
|
|
835
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
836
|
+
else {
|
|
837
|
+
return;
|
|
838
|
+
};
|
|
839
|
+
let is_retired = lifecycle
|
|
840
|
+
.get(agent_id.as_str())
|
|
841
|
+
.and_then(|entry| entry.get("state"))
|
|
842
|
+
.and_then(serde_json::Value::as_str)
|
|
843
|
+
== Some("retired");
|
|
844
|
+
if is_retired {
|
|
845
|
+
lifecycle.remove(agent_id.as_str());
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
786
849
|
/// Build the persisted spec after removing one worker. Besides deleting the worker and startup entry,
|
|
787
850
|
/// prune routing references that would otherwise point at the removed worker.
|
|
788
851
|
pub(crate) fn spec_without_agent(spec: &YamlValue, agent_id: &AgentId) -> YamlValue {
|
|
@@ -16,6 +16,29 @@ use super::*;
|
|
|
16
16
|
struct LifecyclePaths {
|
|
17
17
|
run_workspace: std::path::PathBuf,
|
|
18
18
|
spec_workspace: std::path::PathBuf,
|
|
19
|
+
selected: Option<crate::state::selector::SelectedTeam>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl LifecyclePaths {
|
|
23
|
+
fn canonical_team<'a>(&'a self, requested: Option<&'a str>) -> Option<&'a str> {
|
|
24
|
+
self.selected
|
|
25
|
+
.as_ref()
|
|
26
|
+
.map(|selected| selected.team_key.as_str())
|
|
27
|
+
.or(requested)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn tmux_backend(&self) -> Result<crate::tmux_backend::TmuxBackend, LifecycleError> {
|
|
31
|
+
match self.selected.as_ref() {
|
|
32
|
+
Some(selected) => common::lifecycle_worker_tmux_backend_selection_for_state(
|
|
33
|
+
&selected.run_workspace,
|
|
34
|
+
&selected.state,
|
|
35
|
+
)
|
|
36
|
+
.map(|selection| selection.backend),
|
|
37
|
+
None => Ok(crate::tmux_backend::TmuxBackend::for_workspace(
|
|
38
|
+
&self.run_workspace,
|
|
39
|
+
)),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
struct LifecyclePathRefs<'a> {
|
|
@@ -115,8 +138,9 @@ fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePath
|
|
|
115
138
|
LifecycleError::TeamSelect("active team spec workspace not found".to_string())
|
|
116
139
|
})?;
|
|
117
140
|
Ok(LifecyclePaths {
|
|
118
|
-
run_workspace: selected.run_workspace,
|
|
141
|
+
run_workspace: selected.run_workspace.clone(),
|
|
119
142
|
spec_workspace,
|
|
143
|
+
selected: Some(selected),
|
|
120
144
|
})
|
|
121
145
|
}
|
|
122
146
|
|
|
@@ -434,6 +434,319 @@ fn force_remove_reconciles_all_seat_quadrants_without_cross_team_same_name_kill(
|
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
#[test]
|
|
438
|
+
fn remove_persists_retired_tombstone_and_explicit_readd_clears_it() {
|
|
439
|
+
let ws = lanea_ws_agents(json!({
|
|
440
|
+
"alpha": { "status": "stopped", "provider": "codex", "window": "alpha" },
|
|
441
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
442
|
+
}));
|
|
443
|
+
let mut seeded = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
444
|
+
seeded["team_key"] = json!("laneateam");
|
|
445
|
+
seeded["active_team_key"] = json!("laneateam");
|
|
446
|
+
let mut nested = crate::state::projection::compact_team_state(&seeded);
|
|
447
|
+
nested["status"] = json!("alive");
|
|
448
|
+
seeded["teams"] = json!({
|
|
449
|
+
"laneateam": nested,
|
|
450
|
+
"sibling": {
|
|
451
|
+
"team_key": "sibling",
|
|
452
|
+
"status": "shutdown",
|
|
453
|
+
"agents": {},
|
|
454
|
+
"agent_lifecycle": {
|
|
455
|
+
"alpha": {
|
|
456
|
+
"state": "retired",
|
|
457
|
+
"changed_at": "2026-07-20T00:00:00Z",
|
|
458
|
+
"reason": "sibling retirement"
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
crate::state::persist::save_runtime_state(&ws, &seeded).unwrap();
|
|
464
|
+
let stale_tick = crate::state::projection::select_runtime_state(&ws, Some("laneateam"))
|
|
465
|
+
.expect("capture the coordinator projection from before remove");
|
|
466
|
+
let tx = LaneTransport::new("team-laneateam", &[]);
|
|
467
|
+
let role = ws.join("agents").join("alpha.md");
|
|
468
|
+
|
|
469
|
+
remove_agent_with_transport(&ws, &aid("alpha"), true, true, None, &tx).unwrap();
|
|
470
|
+
let removed: serde_json::Value = serde_json::from_str(
|
|
471
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
472
|
+
)
|
|
473
|
+
.unwrap();
|
|
474
|
+
assert_eq!(
|
|
475
|
+
removed["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
|
|
476
|
+
"the first successful remove must directly persist the canonical tombstone"
|
|
477
|
+
);
|
|
478
|
+
assert!(
|
|
479
|
+
removed["teams"]["laneateam"]["agents"]
|
|
480
|
+
.get("alpha")
|
|
481
|
+
.is_none(),
|
|
482
|
+
"the first successful remove must directly delete the canonical agent row"
|
|
483
|
+
);
|
|
484
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
485
|
+
.save(
|
|
486
|
+
crate::state::repository::StateWriteIntent::CoordinatorTick {
|
|
487
|
+
team_key: "laneateam",
|
|
488
|
+
},
|
|
489
|
+
&stale_tick,
|
|
490
|
+
)
|
|
491
|
+
.unwrap();
|
|
492
|
+
let after_stale_tick: serde_json::Value = serde_json::from_str(
|
|
493
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
494
|
+
)
|
|
495
|
+
.unwrap();
|
|
496
|
+
assert_eq!(
|
|
497
|
+
after_stale_tick["teams"]["laneateam"]["agent_lifecycle"]["alpha"]["state"], "retired",
|
|
498
|
+
"a stale coordinator save must not erase the first remove's tombstone"
|
|
499
|
+
);
|
|
500
|
+
assert!(
|
|
501
|
+
after_stale_tick["teams"]["laneateam"]["agents"]
|
|
502
|
+
.get("alpha")
|
|
503
|
+
.is_none(),
|
|
504
|
+
"a stale coordinator save must not resurrect the first removed agent"
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
crate::lifecycle::add_agent_with_transport(&ws, &aid("alpha"), &role, false, None, &tx)
|
|
508
|
+
.unwrap();
|
|
509
|
+
let readded = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
510
|
+
assert!(
|
|
511
|
+
readded["teams"]["laneateam"]["agent_lifecycle"]
|
|
512
|
+
.get("alpha")
|
|
513
|
+
.is_none(),
|
|
514
|
+
"explicit re-add must consume the retired tombstone"
|
|
515
|
+
);
|
|
516
|
+
assert!(readded["teams"]["laneateam"]["agents"]
|
|
517
|
+
.get("alpha")
|
|
518
|
+
.is_some());
|
|
519
|
+
assert_eq!(
|
|
520
|
+
readded["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"], "retired",
|
|
521
|
+
"selected-team re-add must not clear a sibling team's same-id retirement"
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/// Cross-product RED (verifier, slice2b authority leak): the FAILURE dimension
|
|
526
|
+
/// (a stale writer whose incoming projection predates a sibling retirement) x the
|
|
527
|
+
/// BOUNDARY dimension (a sibling team carrying the SAME agent id). The tracked
|
|
528
|
+
/// `remove_persists_..._readd_clears_it` sibling assertion covers only the
|
|
529
|
+
/// non-stale corner (sibling tombstone already current in the add's input); it
|
|
530
|
+
/// does NOT exercise an incoming sibling projection captured BEFORE the sibling
|
|
531
|
+
/// retirement. topology-authority is keyed by bare `agent_id` with no team
|
|
532
|
+
/// dimension (repository.rs:277-283 drops team_key; persist.rs iterates every
|
|
533
|
+
/// sibling team with the same global id set), so an explicit AddAgent(alpha) in
|
|
534
|
+
/// team A exempts alpha in team B too and a stale incoming resurrects B's alpha /
|
|
535
|
+
/// erases B's retirement tombstone. Red at da11f98c: teamB.alpha lifecycle Null.
|
|
536
|
+
#[test]
|
|
537
|
+
fn add_agent_authority_must_not_cross_team_erase_sibling_same_id_retirement() {
|
|
538
|
+
// Disk latest: team A alive with alpha; team B alpha ABSENT + retired.
|
|
539
|
+
let ws = lanea_ws_agents(json!({
|
|
540
|
+
"alpha": { "status": "running", "provider": "codex", "window": "alpha" },
|
|
541
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
542
|
+
}));
|
|
543
|
+
let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
544
|
+
latest["team_key"] = json!("laneateam");
|
|
545
|
+
latest["active_team_key"] = json!("laneateam");
|
|
546
|
+
let mut nested_a = crate::state::projection::compact_team_state(&latest);
|
|
547
|
+
nested_a["status"] = json!("alive");
|
|
548
|
+
latest["teams"] = json!({
|
|
549
|
+
"laneateam": nested_a,
|
|
550
|
+
"sibling": {
|
|
551
|
+
"team_key": "sibling",
|
|
552
|
+
"status": "alive",
|
|
553
|
+
"agents": {},
|
|
554
|
+
"agent_lifecycle": {
|
|
555
|
+
"alpha": {
|
|
556
|
+
"state": "retired",
|
|
557
|
+
"changed_at": "2026-07-21T00:00:00Z",
|
|
558
|
+
"reason": "sibling retirement (newer than the stale incoming)"
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
|
|
564
|
+
|
|
565
|
+
// Incoming STALE root: captured BEFORE team B retired alpha, so its sibling
|
|
566
|
+
// entry still carries alpha ACTIVE with no tombstone. A concurrent explicit
|
|
567
|
+
// AddAgent(alpha) in team A carries this whole stale multi-team projection.
|
|
568
|
+
let mut incoming_stale = latest.clone();
|
|
569
|
+
incoming_stale["teams"]["sibling"] = json!({
|
|
570
|
+
"team_key": "sibling",
|
|
571
|
+
"status": "alive",
|
|
572
|
+
"agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
|
|
573
|
+
"agent_lifecycle": {}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
577
|
+
.save(
|
|
578
|
+
crate::state::repository::StateWriteIntent::AddAgent {
|
|
579
|
+
team_key: "laneateam",
|
|
580
|
+
agent_id: "alpha",
|
|
581
|
+
},
|
|
582
|
+
&incoming_stale,
|
|
583
|
+
)
|
|
584
|
+
.unwrap();
|
|
585
|
+
|
|
586
|
+
let after: serde_json::Value = serde_json::from_str(
|
|
587
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
588
|
+
)
|
|
589
|
+
.unwrap();
|
|
590
|
+
// The AddAgent authority is scoped to team A's alpha ONLY; team B's newer
|
|
591
|
+
// same-id retirement must survive (the stale incoming must not resurrect it).
|
|
592
|
+
assert_eq!(
|
|
593
|
+
after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
|
|
594
|
+
json!("retired"),
|
|
595
|
+
"selected-team AddAgent authority must not cross team boundary by bare agent id: \
|
|
596
|
+
team B's newer same-id retirement was erased by team A's stale re-add"
|
|
597
|
+
);
|
|
598
|
+
assert!(
|
|
599
|
+
after["teams"]["sibling"]["agents"].get("alpha").is_none(),
|
|
600
|
+
"a stale incoming must not resurrect the sibling team's retired same-id agent"
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/// Identity-source boundary (verifier, slice2b authority fix new adjudication
|
|
605
|
+
/// face, same family as slice1 empty-current): the legacy top-level scope anchor
|
|
606
|
+
/// now prefers `active_team_key`. Verify it does not over-exempt / under-protect
|
|
607
|
+
/// when `active_team_key` is a NON-EMPTY but TERMINAL / mismatched value (points
|
|
608
|
+
/// at a team not present in `teams`). The AddAgent authority is (laneateam,
|
|
609
|
+
/// alpha); with a ghost active_team_key, the top-level filter must resolve NO
|
|
610
|
+
/// exemption for the ghost, so a real retirement anywhere is still protected —
|
|
611
|
+
/// the anchor must not silently widen authority to a bare id under a dangling
|
|
612
|
+
/// active_team_key (that would be the empty-current forgery shape at slice1).
|
|
613
|
+
#[test]
|
|
614
|
+
fn terminal_active_team_key_does_not_widen_topology_authority_to_bare_id() {
|
|
615
|
+
// Disk latest: laneateam alive with alpha; sibling alpha ABSENT + retired.
|
|
616
|
+
let ws = lanea_ws_agents(json!({
|
|
617
|
+
"alpha": { "status": "running", "provider": "codex", "window": "alpha" },
|
|
618
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
619
|
+
}));
|
|
620
|
+
let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
621
|
+
latest["team_key"] = json!("laneateam");
|
|
622
|
+
latest["active_team_key"] = json!("laneateam");
|
|
623
|
+
let mut nested_a = crate::state::projection::compact_team_state(&latest);
|
|
624
|
+
nested_a["status"] = json!("alive");
|
|
625
|
+
latest["teams"] = json!({
|
|
626
|
+
"laneateam": nested_a,
|
|
627
|
+
"sibling": {
|
|
628
|
+
"team_key": "sibling",
|
|
629
|
+
"status": "alive",
|
|
630
|
+
"agents": {},
|
|
631
|
+
"agent_lifecycle": {
|
|
632
|
+
"alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
|
|
637
|
+
|
|
638
|
+
// Incoming STALE root whose active_team_key points at a GHOST team not in
|
|
639
|
+
// `teams`, and whose sibling entry is the pre-retire (alpha active) shape.
|
|
640
|
+
let mut incoming_stale = latest.clone();
|
|
641
|
+
incoming_stale["active_team_key"] = json!("ghostteam-does-not-exist");
|
|
642
|
+
incoming_stale["teams"]["sibling"] = json!({
|
|
643
|
+
"team_key": "sibling",
|
|
644
|
+
"status": "alive",
|
|
645
|
+
"agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
|
|
646
|
+
"agent_lifecycle": {}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
650
|
+
.save(
|
|
651
|
+
crate::state::repository::StateWriteIntent::AddAgent {
|
|
652
|
+
team_key: "laneateam",
|
|
653
|
+
agent_id: "alpha",
|
|
654
|
+
},
|
|
655
|
+
&incoming_stale,
|
|
656
|
+
)
|
|
657
|
+
.unwrap();
|
|
658
|
+
|
|
659
|
+
let after: serde_json::Value = serde_json::from_str(
|
|
660
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
661
|
+
)
|
|
662
|
+
.unwrap();
|
|
663
|
+
// A dangling active_team_key must not widen the (laneateam, alpha) authority
|
|
664
|
+
// into a bare `alpha` exemption that reaches the sibling: sibling stays
|
|
665
|
+
// retired, not resurrected.
|
|
666
|
+
assert_eq!(
|
|
667
|
+
after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
|
|
668
|
+
json!("retired"),
|
|
669
|
+
"a terminal/ghost active_team_key must not widen topology authority to a bare id \
|
|
670
|
+
and erase the sibling's same-id retirement"
|
|
671
|
+
);
|
|
672
|
+
assert!(
|
|
673
|
+
after["teams"]["sibling"]["agents"].get("alpha").is_none(),
|
|
674
|
+
"a terminal active_team_key must not let a stale incoming resurrect the sibling agent"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/// Cross-product cell (verifier, slice2b current-alias fix): the `current`
|
|
679
|
+
/// physical alias dimension x the sibling same-id retirement dimension. The
|
|
680
|
+
/// reset fix routes `team == "current"` authority through `top_level_team`
|
|
681
|
+
/// (active_team_key). This pins that the alias branch does NOT leak that
|
|
682
|
+
/// active-team authority into a REAL sibling that merely shares the agent id:
|
|
683
|
+
/// a `current` alias entry (= the active team's physical projection) plus a
|
|
684
|
+
/// distinct sibling team both carrying `alpha`, sibling retired, stale incoming
|
|
685
|
+
/// resurrecting sibling alpha. The current-alias authority must clear only the
|
|
686
|
+
/// active team's own alpha, never the sibling's newer retirement.
|
|
687
|
+
#[test]
|
|
688
|
+
fn current_alias_authority_does_not_leak_into_real_sibling_same_id_retirement() {
|
|
689
|
+
let ws = lanea_ws_agents(json!({
|
|
690
|
+
"alpha": { "status": "running", "provider": "codex", "window": "alpha" },
|
|
691
|
+
"bravo": { "status": "running", "provider": "codex", "window": "bravo" }
|
|
692
|
+
}));
|
|
693
|
+
let mut latest = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
694
|
+
latest["team_key"] = json!("laneateam");
|
|
695
|
+
latest["active_team_key"] = json!("laneateam");
|
|
696
|
+
let mut nested_a = crate::state::projection::compact_team_state(&latest);
|
|
697
|
+
nested_a["status"] = json!("alive");
|
|
698
|
+
// `current` is the physical alias of the active team (laneateam); a REAL
|
|
699
|
+
// sibling team also carries alpha, retired and newer than the stale incoming.
|
|
700
|
+
latest["teams"] = json!({
|
|
701
|
+
"laneateam": nested_a.clone(),
|
|
702
|
+
"current": nested_a,
|
|
703
|
+
"sibling": {
|
|
704
|
+
"team_key": "sibling",
|
|
705
|
+
"status": "alive",
|
|
706
|
+
"agents": {},
|
|
707
|
+
"agent_lifecycle": {
|
|
708
|
+
"alpha": { "state": "retired", "changed_at": "2026-07-21T00:00:00Z", "reason": "sibling" }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
crate::state::persist::save_runtime_state(&ws, &latest).unwrap();
|
|
713
|
+
|
|
714
|
+
let mut incoming_stale = latest.clone();
|
|
715
|
+
incoming_stale["teams"]["sibling"] = json!({
|
|
716
|
+
"team_key": "sibling",
|
|
717
|
+
"status": "alive",
|
|
718
|
+
"agents": { "alpha": { "status": "running", "provider": "codex", "window": "alpha" } },
|
|
719
|
+
"agent_lifecycle": {}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
crate::state::repository::StateRepository::new(&ws)
|
|
723
|
+
.save(
|
|
724
|
+
crate::state::repository::StateWriteIntent::AddAgent {
|
|
725
|
+
team_key: "laneateam",
|
|
726
|
+
agent_id: "alpha",
|
|
727
|
+
},
|
|
728
|
+
&incoming_stale,
|
|
729
|
+
)
|
|
730
|
+
.unwrap();
|
|
731
|
+
|
|
732
|
+
let after: serde_json::Value = serde_json::from_str(
|
|
733
|
+
&std::fs::read_to_string(crate::state::persist::runtime_state_path(&ws)).unwrap(),
|
|
734
|
+
)
|
|
735
|
+
.unwrap();
|
|
736
|
+
// The current-alias authority is the ACTIVE team's; it must not reach the
|
|
737
|
+
// real sibling that only shares the id.
|
|
738
|
+
assert_eq!(
|
|
739
|
+
after["teams"]["sibling"]["agent_lifecycle"]["alpha"]["state"],
|
|
740
|
+
json!("retired"),
|
|
741
|
+
"current-alias authority must not leak into a real sibling's same-id retirement"
|
|
742
|
+
);
|
|
743
|
+
assert!(
|
|
744
|
+
after["teams"]["sibling"]["agents"].get("alpha").is_none(),
|
|
745
|
+
"a stale incoming must not resurrect the real sibling's retired same-id agent \
|
|
746
|
+
via the current-alias authority path"
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
437
750
|
const CHARLIE_ROLE: &str = "---\nname: charlie\nrole: Charlie Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nCharlie.\n";
|
|
438
751
|
|
|
439
752
|
struct HermeticTestEnv {
|
|
@@ -558,6 +871,10 @@ fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_
|
|
|
558
871
|
state["agents"].get("charlie").is_some(),
|
|
559
872
|
"{quadrant}: state restored"
|
|
560
873
|
);
|
|
874
|
+
assert!(
|
|
875
|
+
state["agent_lifecycle"].get("charlie").is_none(),
|
|
876
|
+
"{quadrant}: successful force-recreate consumes its interim tombstone"
|
|
877
|
+
);
|
|
561
878
|
let spec_text = std::fs::read_to_string(selected_spec_path(&ws)).unwrap();
|
|
562
879
|
assert_eq!(
|
|
563
880
|
spec_text.matches("id: \"charlie\"").count(),
|
|
@@ -604,6 +921,10 @@ fn force_recreate_is_one_lock_transaction_for_all_seat_quadrants_and_rolls_back_
|
|
|
604
921
|
state["agents"].get("charlie").is_some(),
|
|
605
922
|
"rollback restores state"
|
|
606
923
|
);
|
|
924
|
+
assert!(
|
|
925
|
+
state["agent_lifecycle"].get("charlie").is_none(),
|
|
926
|
+
"preflight failure must not create a retirement tombstone"
|
|
927
|
+
);
|
|
607
928
|
assert!(
|
|
608
929
|
std::fs::read_to_string(selected_spec_path(&ws))
|
|
609
930
|
.unwrap()
|
|
@@ -702,6 +1023,11 @@ fn force_recreate_post_consumption_failure_restores_before_tuple_and_health() {
|
|
|
702
1023
|
sibling_health,
|
|
703
1024
|
"rollback never changes the sibling team's same-agent health row"
|
|
704
1025
|
);
|
|
1026
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1027
|
+
assert!(
|
|
1028
|
+
state["agent_lifecycle"].get("charlie").is_none(),
|
|
1029
|
+
"post-consumption rollback restores the before-state without a new tombstone"
|
|
1030
|
+
);
|
|
705
1031
|
}
|
|
706
1032
|
|
|
707
1033
|
#[test]
|
|
@@ -1140,6 +1466,11 @@ fn lanea_remove_rollback_restarts_force_stopped_worker() {
|
|
|
1140
1466
|
RemoveRollback::restore. spawns={:?}",
|
|
1141
1467
|
tx.spawns()
|
|
1142
1468
|
);
|
|
1469
|
+
let restored = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
1470
|
+
assert!(
|
|
1471
|
+
restored["agent_lifecycle"].get("alpha").is_none(),
|
|
1472
|
+
"failed remove must roll back the transaction-created tombstone"
|
|
1473
|
+
);
|
|
1143
1474
|
}
|
|
1144
1475
|
|
|
1145
1476
|
// ── REMOVE #11 (remove-dynamic-role-path-and-required-8, warn) [RED] — missing REQUIRED role file raises
|