@team-agent/installer 0.3.17 → 0.3.18
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/mod.rs +26 -7
- package/crates/team-agent/src/cli/status_port.rs +3 -6
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +41 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +21 -0
- package/crates/team-agent/src/lifecycle/launch.rs +6 -6
- package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -6
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +42 -4
- package/crates/team-agent/src/lifecycle/types.rs +4 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +10 -1
- package/crates/team-agent/src/state/persist.rs +85 -8
- package/crates/team-agent/src/state/projection.rs +48 -3
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -363,10 +363,7 @@ pub mod lifecycle_port {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
fn state_uses_external_leader(state: &Value) -> bool {
|
|
366
|
-
state
|
|
367
|
-
.get("is_external_leader")
|
|
368
|
-
.and_then(Value::as_bool)
|
|
369
|
-
.unwrap_or(true)
|
|
366
|
+
crate::state::projection::state_is_external_leader(state)
|
|
370
367
|
}
|
|
371
368
|
|
|
372
369
|
fn managed_leader_socket_cleanup(
|
|
@@ -1722,9 +1719,31 @@ pub mod lifecycle_port {
|
|
|
1722
1719
|
open_display,
|
|
1723
1720
|
team,
|
|
1724
1721
|
) {
|
|
1725
|
-
Ok(
|
|
1726
|
-
|
|
1727
|
-
|
|
1722
|
+
Ok(crate::lifecycle::ResetAgentOutcome::Reset {
|
|
1723
|
+
env,
|
|
1724
|
+
start_mode,
|
|
1725
|
+
discarded_session_id,
|
|
1726
|
+
session_id,
|
|
1727
|
+
new_session_id,
|
|
1728
|
+
}) => Ok(json!({
|
|
1729
|
+
"ok": true,
|
|
1730
|
+
"agent_id": env.agent_id.as_str(),
|
|
1731
|
+
"status": "reset",
|
|
1732
|
+
"state_file": env.state_file.to_string_lossy().to_string(),
|
|
1733
|
+
"coordinator_started": env.coordinator_started,
|
|
1734
|
+
"start_mode": start_mode,
|
|
1735
|
+
"discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
|
|
1736
|
+
"session_id": session_id.as_ref().map(|id| id.as_str()),
|
|
1737
|
+
"new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
|
|
1738
|
+
})),
|
|
1739
|
+
Ok(crate::lifecycle::ResetAgentOutcome::Refused { reason }) => Ok(json!({
|
|
1740
|
+
"ok": false,
|
|
1741
|
+
"agent_id": agent,
|
|
1742
|
+
"status": "refused",
|
|
1743
|
+
"reason": match reason {
|
|
1744
|
+
crate::lifecycle::ResetRefusal::DiscardSessionRequired => "discard_session_required",
|
|
1745
|
+
},
|
|
1746
|
+
})),
|
|
1728
1747
|
Err(e) => Ok(error_value(e)),
|
|
1729
1748
|
}
|
|
1730
1749
|
}
|
|
@@ -48,10 +48,7 @@ use rusqlite::params;
|
|
|
48
48
|
.cloned()
|
|
49
49
|
.unwrap_or_else(|| json!({}));
|
|
50
50
|
let session_name = state.get("session_name").cloned().unwrap_or(Value::Null);
|
|
51
|
-
let is_external_leader = state
|
|
52
|
-
.get("is_external_leader")
|
|
53
|
-
.and_then(Value::as_bool)
|
|
54
|
-
.unwrap_or(true);
|
|
51
|
+
let is_external_leader = crate::state::projection::state_is_external_leader(state);
|
|
55
52
|
let leader_topology = if is_external_leader { "external" } else { "managed" };
|
|
56
53
|
let leader_attach_command = if is_external_leader {
|
|
57
54
|
None
|
|
@@ -573,8 +570,8 @@ use rusqlite::params;
|
|
|
573
570
|
json!({
|
|
574
571
|
"team": full.get("team").cloned().unwrap_or(Value::Null),
|
|
575
572
|
"session_name": full.get("session_name").cloned().unwrap_or(Value::Null),
|
|
576
|
-
"leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("
|
|
577
|
-
"is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(
|
|
573
|
+
"leader_topology": full.get("leader_topology").cloned().unwrap_or_else(|| json!("managed")),
|
|
574
|
+
"is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(false)),
|
|
578
575
|
"leader_attach_command": full.get("leader_attach_command").cloned().unwrap_or(Value::Null),
|
|
579
576
|
"leader_client": full.get("leader_client").cloned().unwrap_or(Value::Null),
|
|
580
577
|
"tmux_session_present": full.get("tmux_session_present").cloned().unwrap_or(Value::Bool(false)),
|
|
@@ -309,6 +309,7 @@ fn lsof_cwd_timeout_is_diagnostic_not_shutdown_partial() {
|
|
|
309
309
|
&ws,
|
|
310
310
|
&json!({
|
|
311
311
|
"session_name": "team-lsof-cwd-timeout",
|
|
312
|
+
"is_external_leader": true,
|
|
312
313
|
"agents": {
|
|
313
314
|
"fake_impl": {
|
|
314
315
|
"status": "running",
|
|
@@ -467,6 +468,7 @@ fn leader_env_tmux_socket_never_kills_server_even_when_sessions_look_exclusive()
|
|
|
467
468
|
crate::state::persist::save_runtime_state(
|
|
468
469
|
&ws,
|
|
469
470
|
&json!({
|
|
471
|
+
"is_external_leader": true,
|
|
470
472
|
"tmux_socket_source": "leader_env",
|
|
471
473
|
"agents": {
|
|
472
474
|
"fake_impl": {
|
|
@@ -543,3 +545,42 @@ fn managed_leader_shutdown_never_kills_server_even_when_socket_looks_exclusive()
|
|
|
543
545
|
"managed topology must clear the team session/window without kill-server"
|
|
544
546
|
);
|
|
545
547
|
}
|
|
548
|
+
|
|
549
|
+
#[test]
|
|
550
|
+
fn shutdown_missing_topology_marker_defaults_to_managed_cleanup() {
|
|
551
|
+
let ws = tmp_shutdown_workspace("missing-topology-marker-managed-cleanup");
|
|
552
|
+
crate::state::persist::save_runtime_state(
|
|
553
|
+
&ws,
|
|
554
|
+
&json!({
|
|
555
|
+
"session_name": "team-current",
|
|
556
|
+
"agents": {}
|
|
557
|
+
}),
|
|
558
|
+
)
|
|
559
|
+
.unwrap();
|
|
560
|
+
let transport = CleanShutdownTransport::new()
|
|
561
|
+
.with_targets(vec![PaneInfo {
|
|
562
|
+
pane_id: PaneId::new("%1"),
|
|
563
|
+
session: SessionName::new("team-current"),
|
|
564
|
+
window_index: Some(0),
|
|
565
|
+
window_name: Some(WindowName::new("leader")),
|
|
566
|
+
pane_index: Some(0),
|
|
567
|
+
tty: None,
|
|
568
|
+
current_command: Some("codex".to_string()),
|
|
569
|
+
current_path: None,
|
|
570
|
+
active: true,
|
|
571
|
+
pane_pid: None,
|
|
572
|
+
leader_env: BTreeMap::new(),
|
|
573
|
+
}])
|
|
574
|
+
.with_targets_persist_after_kill();
|
|
575
|
+
|
|
576
|
+
let out = crate::cli::lifecycle_port::shutdown_with_transport(&ws, true, None, &transport)
|
|
577
|
+
.expect("shutdown should complete");
|
|
578
|
+
|
|
579
|
+
assert_eq!(out["ok"], json!(true));
|
|
580
|
+
assert_eq!(out["killed_sessions"], json!(["team-current"]));
|
|
581
|
+
assert_eq!(out["spared_sessions"], json!([]));
|
|
582
|
+
assert!(
|
|
583
|
+
!transport.kill_server_called(),
|
|
584
|
+
"missing topology marker must default to managed cleanup, not external kill-server"
|
|
585
|
+
);
|
|
586
|
+
}
|
|
@@ -86,6 +86,27 @@ use super::*;
|
|
|
86
86
|
let _ = std::fs::remove_dir_all(&ws);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
#[test]
|
|
90
|
+
fn status_port_missing_topology_marker_defaults_to_managed() {
|
|
91
|
+
let ws = seed_status_workspace();
|
|
92
|
+
let mut state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
93
|
+
if let Some(obj) = state.as_object_mut() {
|
|
94
|
+
obj.remove("is_external_leader");
|
|
95
|
+
obj.insert("session_name".to_string(), json!("team-current"));
|
|
96
|
+
}
|
|
97
|
+
crate::state::persist::save_runtime_state(&ws, &state).unwrap();
|
|
98
|
+
|
|
99
|
+
let v = status_port::status(&ws, /*compact=*/ true, /*detail=*/ false).expect("status");
|
|
100
|
+
|
|
101
|
+
assert_eq!(v["leader_topology"], json!("managed"));
|
|
102
|
+
assert_eq!(v["is_external_leader"], json!(false));
|
|
103
|
+
let attach = v["leader_attach_command"]
|
|
104
|
+
.as_str()
|
|
105
|
+
.expect("missing marker defaults to managed attach command");
|
|
106
|
+
assert!(attach.contains("attach -t team-current:leader"), "{attach}");
|
|
107
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
108
|
+
}
|
|
109
|
+
|
|
89
110
|
#[test]
|
|
90
111
|
fn status_port_status_detail_full_keeps_uncompacted_events() {
|
|
91
112
|
// cmd_status --json --detail -> status_port::status(compact=false): the FULL dict
|
|
@@ -856,6 +856,8 @@ fn save_launched_team_state_for_key(
|
|
|
856
856
|
"active_team_key".to_string(),
|
|
857
857
|
serde_json::Value::String(launched_key.clone()),
|
|
858
858
|
);
|
|
859
|
+
obj.entry("is_external_leader".to_string())
|
|
860
|
+
.or_insert(serde_json::Value::Bool(false));
|
|
859
861
|
}
|
|
860
862
|
promote_launched_binding_from_team_entry(&mut launched, &launched_key);
|
|
861
863
|
preserve_existing_leader_topology(&existing, &launched_key, &mut launched);
|
|
@@ -3021,10 +3023,7 @@ fn attach_window_names_with_managed_leader(
|
|
|
3021
3023
|
}
|
|
3022
3024
|
|
|
3023
3025
|
fn state_uses_managed_leader(state: &serde_json::Value) -> bool {
|
|
3024
|
-
state
|
|
3025
|
-
.get("is_external_leader")
|
|
3026
|
-
.and_then(serde_json::Value::as_bool)
|
|
3027
|
-
.is_some_and(|external| !external)
|
|
3026
|
+
crate::state::projection::state_is_managed_leader(state)
|
|
3028
3027
|
}
|
|
3029
3028
|
|
|
3030
3029
|
/// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
|
|
@@ -3087,7 +3086,7 @@ fn quick_start_session_capture_incomplete_agents(workspace: &Path, team_key: &st
|
|
|
3087
3086
|
crate::session_capture::incomplete_interacted_resumable_agent_ids(team_state)
|
|
3088
3087
|
}
|
|
3089
3088
|
|
|
3090
|
-
fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool {
|
|
3089
|
+
pub(crate) fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool {
|
|
3091
3090
|
let Ok(state) = load_runtime_state(workspace) else {
|
|
3092
3091
|
return true;
|
|
3093
3092
|
};
|
|
@@ -3097,7 +3096,7 @@ fn launched_team_receiver_is_attached(workspace: &Path, team_key: &str) -> bool
|
|
|
3097
3096
|
.and_then(|teams| teams.get(team_key))
|
|
3098
3097
|
.unwrap_or(&state);
|
|
3099
3098
|
if team_state.get("leader_receiver").is_none() {
|
|
3100
|
-
return
|
|
3099
|
+
return crate::state::projection::state_is_external_leader(team_state);
|
|
3101
3100
|
}
|
|
3102
3101
|
if team_uses_fake_model_harness(team_state) {
|
|
3103
3102
|
return true;
|
|
@@ -4366,6 +4365,7 @@ fn initial_runtime_state(
|
|
|
4366
4365
|
"display_backend".to_string(),
|
|
4367
4366
|
serde_json::json!(display_backend),
|
|
4368
4367
|
);
|
|
4368
|
+
state.insert("is_external_leader".to_string(), serde_json::json!(false));
|
|
4369
4369
|
let mut state = serde_json::Value::Object(state);
|
|
4370
4370
|
if !seed_launched_owner_from_env(&mut state) {
|
|
4371
4371
|
let team_id = crate::state::projection::team_state_key(&state);
|
|
@@ -206,6 +206,7 @@ pub(crate) fn start_agent_at_paths(
|
|
|
206
206
|
start_mode,
|
|
207
207
|
target: spawn.spawn.pane_id.as_str().to_string(),
|
|
208
208
|
session_id,
|
|
209
|
+
new_session_id: spawn.plan.expected_session_id.clone(),
|
|
209
210
|
rollout_path,
|
|
210
211
|
})
|
|
211
212
|
}
|
|
@@ -528,8 +529,8 @@ fn reset_agent_at_paths(
|
|
|
528
529
|
.and_then(|v| v.get(agent_id.as_str()))
|
|
529
530
|
.and_then(|v| v.get("session_id"))
|
|
530
531
|
.and_then(|v| v.as_str())
|
|
531
|
-
.
|
|
532
|
-
.
|
|
532
|
+
.filter(|session| !session.is_empty())
|
|
533
|
+
.map(SessionId::new);
|
|
533
534
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state_before_stop, Some(agent_id))?;
|
|
534
535
|
let stop = stop_agent_at_paths(workspace, spec_workspace, agent_id, team, transport)?;
|
|
535
536
|
let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
|
|
@@ -537,11 +538,22 @@ fn reset_agent_at_paths(
|
|
|
537
538
|
discard_agent_session_fields(&mut state, agent_id)?;
|
|
538
539
|
// golden operations.py (reset): save_team_scoped_state on the team projection — same multi-team
|
|
539
540
|
// preservation as stop, not a raw save_runtime_state.
|
|
540
|
-
crate::state::projection::
|
|
541
|
+
crate::state::projection::save_team_scoped_state_with_tombstoned_agents(
|
|
542
|
+
workspace,
|
|
543
|
+
&state,
|
|
544
|
+
&[agent_id.as_str()],
|
|
545
|
+
)
|
|
541
546
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
542
547
|
// golden operations.py:125: write_team_state after the discard-save (the intermediate stopped snapshot).
|
|
543
548
|
write_team_state(spec_workspace, &spec, &state)?;
|
|
544
|
-
write_reset_tombstone_event(
|
|
549
|
+
write_reset_tombstone_event(
|
|
550
|
+
workspace,
|
|
551
|
+
agent_id,
|
|
552
|
+
discarded_session_id
|
|
553
|
+
.as_ref()
|
|
554
|
+
.map(SessionId::as_str)
|
|
555
|
+
.unwrap_or(""),
|
|
556
|
+
)?;
|
|
545
557
|
let start = start_agent_at_paths(
|
|
546
558
|
workspace,
|
|
547
559
|
spec_workspace,
|
|
@@ -556,11 +568,34 @@ fn reset_agent_at_paths(
|
|
|
556
568
|
write_reset_complete_event(workspace, agent_id, stop.stopped, started)?;
|
|
557
569
|
match start {
|
|
558
570
|
StartAgentOutcome::Running {
|
|
559
|
-
env,
|
|
560
|
-
|
|
571
|
+
env,
|
|
572
|
+
start_mode,
|
|
573
|
+
session_id,
|
|
574
|
+
new_session_id,
|
|
575
|
+
..
|
|
576
|
+
} => {
|
|
577
|
+
let output_session_id = if matches!(
|
|
578
|
+
start_mode,
|
|
579
|
+
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
580
|
+
) {
|
|
581
|
+
new_session_id.clone().or(session_id)
|
|
582
|
+
} else {
|
|
583
|
+
session_id
|
|
584
|
+
};
|
|
585
|
+
Ok(ResetAgentOutcome::Reset {
|
|
586
|
+
env,
|
|
587
|
+
start_mode,
|
|
588
|
+
discarded_session_id,
|
|
589
|
+
session_id: output_session_id,
|
|
590
|
+
new_session_id,
|
|
591
|
+
})
|
|
592
|
+
}
|
|
561
593
|
StartAgentOutcome::Noop { env, .. } => Ok(ResetAgentOutcome::Reset {
|
|
562
594
|
env,
|
|
563
595
|
start_mode: StartMode::Noop,
|
|
596
|
+
discarded_session_id,
|
|
597
|
+
session_id: None,
|
|
598
|
+
new_session_id: None,
|
|
564
599
|
}),
|
|
565
600
|
StartAgentOutcome::Paused { .. } => Ok(ResetAgentOutcome::Reset {
|
|
566
601
|
env: AgentActionEnvelope {
|
|
@@ -569,6 +604,9 @@ fn reset_agent_at_paths(
|
|
|
569
604
|
coordinator_started: false,
|
|
570
605
|
},
|
|
571
606
|
start_mode: StartMode::Noop,
|
|
607
|
+
discarded_session_id,
|
|
608
|
+
session_id: None,
|
|
609
|
+
new_session_id: None,
|
|
572
610
|
}),
|
|
573
611
|
}
|
|
574
612
|
}
|
|
@@ -1200,9 +1200,15 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
|
|
|
1200
1200
|
.any(|cmd| cmd.contains(&format!("tmux -S {endpoint} attach -t "))),
|
|
1201
1201
|
"attach commands must use the selected socket endpoint: {attach_commands:?}"
|
|
1202
1202
|
);
|
|
1203
|
+
assert!(
|
|
1204
|
+
attach_commands.iter().any(|cmd| cmd.contains(":leader")),
|
|
1205
|
+
"fresh managed quick-start must include the leader window attach command: {attach_commands:?}"
|
|
1206
|
+
);
|
|
1203
1207
|
let (_raw, state) = raw_runtime_state(workspace);
|
|
1204
1208
|
assert_eq!(state["tmux_endpoint"], json!(endpoint));
|
|
1205
1209
|
assert_eq!(state["tmux_socket"], json!(endpoint));
|
|
1210
|
+
assert_eq!(state["is_external_leader"], json!(false));
|
|
1211
|
+
assert_eq!(state["teams"]["teamdir"]["is_external_leader"], json!(false));
|
|
1206
1212
|
}
|
|
1207
1213
|
|
|
1208
1214
|
#[test]
|
|
@@ -1249,6 +1255,33 @@ fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command
|
|
|
1249
1255
|
assert_eq!(state["leader_client"]["diagnostic_only"], json!(true));
|
|
1250
1256
|
}
|
|
1251
1257
|
|
|
1258
|
+
#[test]
|
|
1259
|
+
fn fresh_managed_state_without_receiver_is_not_attached() {
|
|
1260
|
+
let workspace = temp_ws();
|
|
1261
|
+
crate::state::persist::save_runtime_state(
|
|
1262
|
+
&workspace,
|
|
1263
|
+
&json!({
|
|
1264
|
+
"active_team_key": "teamdir",
|
|
1265
|
+
"session_name": "team-teamdir",
|
|
1266
|
+
"is_external_leader": false,
|
|
1267
|
+
"agents": {"implementer": {"status": "running"}},
|
|
1268
|
+
"teams": {
|
|
1269
|
+
"teamdir": {
|
|
1270
|
+
"session_name": "team-teamdir",
|
|
1271
|
+
"is_external_leader": false,
|
|
1272
|
+
"agents": {"implementer": {"status": "running"}}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}),
|
|
1276
|
+
)
|
|
1277
|
+
.unwrap();
|
|
1278
|
+
|
|
1279
|
+
assert!(
|
|
1280
|
+
!crate::lifecycle::launch::launched_team_receiver_is_attached(&workspace, "teamdir"),
|
|
1281
|
+
"managed topology with missing leader_receiver must not be reported attached"
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1252
1285
|
#[test]
|
|
1253
1286
|
fn attach_window_names_for_state_agents_include_managed_leader_and_layout_windows() {
|
|
1254
1287
|
let state = json!({
|
|
@@ -2225,9 +2258,11 @@ fn quick_start_seeds_tasks_key_from_compiled_spec() {
|
|
|
2225
2258
|
// cross-team reads cannot accidentally pick up another team's binding from the root.
|
|
2226
2259
|
// The post-launch hook drops the top-level owner triple when the launched pane is
|
|
2227
2260
|
// unbound (empty pane_id), and only the per-team entry retains the binding.
|
|
2228
|
-
//
|
|
2229
|
-
//
|
|
2230
|
-
//
|
|
2261
|
+
// R1: topology is explicit; fresh managed teams carry `is_external_leader=false`
|
|
2262
|
+
// before the team-in-team suffix. Order remains the golden prefix
|
|
2263
|
+
// (spec_path … display_backend) + topology marker + new suffix (active_team_key,
|
|
2264
|
+
// teams). display_backend stays the resolved backend from display/backend.py:12-29
|
|
2265
|
+
// (default adaptive), not the raw optional spec field.
|
|
2231
2266
|
#[test]
|
|
2232
2267
|
#[serial(env)]
|
|
2233
2268
|
fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
@@ -2264,11 +2299,13 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
2264
2299
|
"agents",
|
|
2265
2300
|
"tasks",
|
|
2266
2301
|
"display_backend",
|
|
2302
|
+
"is_external_leader",
|
|
2267
2303
|
"active_team_key",
|
|
2268
2304
|
"teams",
|
|
2269
2305
|
],
|
|
2270
2306
|
"state.json top-level key order must match golden launch/core.py:62-71 \
|
|
2271
|
-
plus Bug 1/2 team-in-team suffix
|
|
2307
|
+
plus R1 topology marker and Bug 1/2 team-in-team suffix \
|
|
2308
|
+
(is_external_leader, active_team_key, teams). \
|
|
2272
2309
|
Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
|
|
2273
2310
|
leader_receiver / owner_epoch OFF the top level — they live ONLY under \
|
|
2274
2311
|
teams[<active_team_key>] so per-team isolation has a single source of \
|
|
@@ -2308,6 +2345,7 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
2308
2345
|
json!("adaptive"),
|
|
2309
2346
|
"adaptive layout directive: resolve_display_backend(None, source='launch') defaults to adaptive"
|
|
2310
2347
|
);
|
|
2348
|
+
assert_eq!(state["is_external_leader"], json!(false));
|
|
2311
2349
|
}
|
|
2312
2350
|
|
|
2313
2351
|
// Stage B1 — golden launch/core.py:238-255 writes the running agent state only after
|
|
@@ -535,6 +535,7 @@ pub enum StartAgentOutcome {
|
|
|
535
535
|
start_mode: StartMode,
|
|
536
536
|
target: String,
|
|
537
537
|
session_id: Option<SessionId>,
|
|
538
|
+
new_session_id: Option<SessionId>,
|
|
538
539
|
rollout_path: Option<RolloutPath>,
|
|
539
540
|
},
|
|
540
541
|
/// 窗口已存在且非 force → noop(`start.py:134`)。
|
|
@@ -565,6 +566,9 @@ pub enum ResetAgentOutcome {
|
|
|
565
566
|
Reset {
|
|
566
567
|
env: AgentActionEnvelope,
|
|
567
568
|
start_mode: StartMode,
|
|
569
|
+
discarded_session_id: Option<SessionId>,
|
|
570
|
+
session_id: Option<SessionId>,
|
|
571
|
+
new_session_id: Option<SessionId>,
|
|
568
572
|
},
|
|
569
573
|
/// 未传 discard_session → 拒绝(不丢上下文的误用保护)。
|
|
570
574
|
Refused { reason: ResetRefusal },
|
|
@@ -60,7 +60,13 @@ pub(crate) fn reset_agent(
|
|
|
60
60
|
)
|
|
61
61
|
.map_err(tool_runtime_error)?
|
|
62
62
|
{
|
|
63
|
-
ResetAgentOutcome::Reset {
|
|
63
|
+
ResetAgentOutcome::Reset {
|
|
64
|
+
env,
|
|
65
|
+
start_mode,
|
|
66
|
+
discarded_session_id,
|
|
67
|
+
session_id,
|
|
68
|
+
new_session_id,
|
|
69
|
+
} => Ok(ToolOk {
|
|
64
70
|
fields: object_fields(serde_json::json!({
|
|
65
71
|
"ok": true,
|
|
66
72
|
"agent_id": env.agent_id.as_str(),
|
|
@@ -68,6 +74,9 @@ pub(crate) fn reset_agent(
|
|
|
68
74
|
"state_file": env.state_file.to_string_lossy().to_string(),
|
|
69
75
|
"coordinator_started": env.coordinator_started,
|
|
70
76
|
"start_mode": enum_value(start_mode),
|
|
77
|
+
"discarded_session_id": discarded_session_id.as_ref().map(|id| id.as_str()),
|
|
78
|
+
"session_id": session_id.as_ref().map(|id| id.as_str()),
|
|
79
|
+
"new_session_id": new_session_id.as_ref().map(|id| id.as_str()),
|
|
71
80
|
})),
|
|
72
81
|
}),
|
|
73
82
|
ResetAgentOutcome::Refused { reason } => Ok(ToolOk {
|
|
@@ -170,13 +170,38 @@ impl Drop for RuntimeLock {
|
|
|
170
170
|
/// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
|
|
171
171
|
/// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
|
|
172
172
|
pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
|
|
173
|
-
|
|
173
|
+
save_runtime_state_with_merge_exceptions(workspace, state, &[], None, &[])
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
177
177
|
workspace: &Path,
|
|
178
178
|
state: &Value,
|
|
179
179
|
deleted_agent_ids: &[&str],
|
|
180
|
+
) -> Result<(), StateError> {
|
|
181
|
+
save_runtime_state_with_merge_exceptions(workspace, state, deleted_agent_ids, None, &[])
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
|
|
185
|
+
workspace: &Path,
|
|
186
|
+
state: &Value,
|
|
187
|
+
tombstoned_team_key: &str,
|
|
188
|
+
tombstoned_agent_ids: &[&str],
|
|
189
|
+
) -> Result<(), StateError> {
|
|
190
|
+
save_runtime_state_with_merge_exceptions(
|
|
191
|
+
workspace,
|
|
192
|
+
state,
|
|
193
|
+
&[],
|
|
194
|
+
Some(tombstoned_team_key),
|
|
195
|
+
tombstoned_agent_ids,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn save_runtime_state_with_merge_exceptions(
|
|
200
|
+
workspace: &Path,
|
|
201
|
+
state: &Value,
|
|
202
|
+
deleted_agent_ids: &[&str],
|
|
203
|
+
skip_capture_backfill_team_key: Option<&str>,
|
|
204
|
+
skip_capture_backfill_agent_ids: &[&str],
|
|
180
205
|
) -> Result<(), StateError> {
|
|
181
206
|
let path = runtime_state_path(workspace);
|
|
182
207
|
if cache_equals(&path, state) {
|
|
@@ -218,7 +243,19 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
|
218
243
|
.filter(|id| !id.is_empty())
|
|
219
244
|
.map(str::to_string)
|
|
220
245
|
.collect::<BTreeSet<_>>();
|
|
221
|
-
|
|
246
|
+
let skip_capture_backfill = skip_capture_backfill_agent_ids
|
|
247
|
+
.iter()
|
|
248
|
+
.copied()
|
|
249
|
+
.filter(|id| !id.is_empty())
|
|
250
|
+
.map(str::to_string)
|
|
251
|
+
.collect::<BTreeSet<_>>();
|
|
252
|
+
preserve_latest_roster_entries(
|
|
253
|
+
&mut migrated,
|
|
254
|
+
&latest,
|
|
255
|
+
&deleted,
|
|
256
|
+
skip_capture_backfill_team_key,
|
|
257
|
+
&skip_capture_backfill,
|
|
258
|
+
);
|
|
222
259
|
}
|
|
223
260
|
// 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
|
|
224
261
|
let payload = serde_json::to_string_pretty(&migrated)?;
|
|
@@ -269,19 +306,34 @@ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value>
|
|
|
269
306
|
Some(latest)
|
|
270
307
|
}
|
|
271
308
|
|
|
272
|
-
fn preserve_latest_roster_entries(
|
|
309
|
+
fn preserve_latest_roster_entries(
|
|
310
|
+
incoming: &mut Value,
|
|
311
|
+
latest: &Value,
|
|
312
|
+
deleted_agent_ids: &BTreeSet<String>,
|
|
313
|
+
skip_capture_backfill_team_key: Option<&str>,
|
|
314
|
+
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
315
|
+
) {
|
|
273
316
|
// A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
|
|
274
317
|
// the top-level<->active-team cross projections depend on which team is active); the
|
|
275
318
|
// per-team `teams.<k>.agents` merge below is team-key self-identifying and must run
|
|
276
319
|
// even when another process flipped session_name/active_team_key between this
|
|
277
320
|
// writer's load and save.
|
|
278
321
|
let projection_matches = same_runtime_projection(incoming, latest);
|
|
322
|
+
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
323
|
+
let top_level_team = Some(team_state_key(incoming)).or_else(|| Some(team_state_key(latest)));
|
|
324
|
+
let skip_top_level_capture_backfill =
|
|
325
|
+
should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
|
|
279
326
|
if projection_matches {
|
|
280
|
-
preserve_missing_agents(
|
|
327
|
+
preserve_missing_agents(
|
|
328
|
+
incoming.get_mut("agents"),
|
|
329
|
+
latest.get("agents"),
|
|
330
|
+
deleted_agent_ids,
|
|
331
|
+
skip_top_level_capture_backfill,
|
|
332
|
+
skip_capture_backfill_agent_ids,
|
|
333
|
+
);
|
|
281
334
|
preserve_latest_ownership_fields(incoming, latest);
|
|
282
335
|
}
|
|
283
336
|
|
|
284
|
-
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
285
337
|
if projection_matches {
|
|
286
338
|
if let Some(active_team) = active_team.as_deref() {
|
|
287
339
|
let latest_active_agents = latest
|
|
@@ -289,7 +341,13 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
289
341
|
.and_then(Value::as_object)
|
|
290
342
|
.and_then(|teams| teams.get(active_team))
|
|
291
343
|
.and_then(|entry| entry.get("agents"));
|
|
292
|
-
preserve_missing_agents(
|
|
344
|
+
preserve_missing_agents(
|
|
345
|
+
incoming.get_mut("agents"),
|
|
346
|
+
latest_active_agents,
|
|
347
|
+
deleted_agent_ids,
|
|
348
|
+
skip_top_level_capture_backfill,
|
|
349
|
+
skip_capture_backfill_agent_ids,
|
|
350
|
+
);
|
|
293
351
|
}
|
|
294
352
|
}
|
|
295
353
|
|
|
@@ -306,6 +364,8 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
306
364
|
incoming_entry.get_mut("agents"),
|
|
307
365
|
latest_entry.get("agents"),
|
|
308
366
|
deleted_agent_ids,
|
|
367
|
+
should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
|
|
368
|
+
skip_capture_backfill_agent_ids,
|
|
309
369
|
);
|
|
310
370
|
preserve_latest_ownership_fields(incoming_entry, latest_entry);
|
|
311
371
|
}
|
|
@@ -314,13 +374,26 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
314
374
|
if let Some(active_team) = active_team.as_deref() {
|
|
315
375
|
let latest_top_agents = latest.get("agents");
|
|
316
376
|
if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
|
|
317
|
-
preserve_missing_agents(
|
|
377
|
+
preserve_missing_agents(
|
|
378
|
+
incoming_entry.get_mut("agents"),
|
|
379
|
+
latest_top_agents,
|
|
380
|
+
deleted_agent_ids,
|
|
381
|
+
should_skip_capture_backfill(Some(active_team), skip_capture_backfill_team_key),
|
|
382
|
+
skip_capture_backfill_agent_ids,
|
|
383
|
+
);
|
|
318
384
|
preserve_latest_ownership_fields(incoming_entry, latest);
|
|
319
385
|
}
|
|
320
386
|
}
|
|
321
387
|
}
|
|
322
388
|
}
|
|
323
389
|
|
|
390
|
+
fn should_skip_capture_backfill(current_team_key: Option<&str>, skip_team_key: Option<&str>) -> bool {
|
|
391
|
+
match skip_team_key {
|
|
392
|
+
Some(skip_team_key) => current_team_key == Some(skip_team_key),
|
|
393
|
+
None => true,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
324
397
|
fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
|
|
325
398
|
if !latest_has_preferable_ownership(incoming, latest) {
|
|
326
399
|
return;
|
|
@@ -379,6 +452,8 @@ fn preserve_missing_agents(
|
|
|
379
452
|
incoming_agents: Option<&mut Value>,
|
|
380
453
|
latest_agents: Option<&Value>,
|
|
381
454
|
deleted_agent_ids: &BTreeSet<String>,
|
|
455
|
+
skip_capture_backfill: bool,
|
|
456
|
+
skip_capture_backfill_agent_ids: &BTreeSet<String>,
|
|
382
457
|
) {
|
|
383
458
|
let Some(incoming_agents) = incoming_agents else {
|
|
384
459
|
return;
|
|
@@ -398,7 +473,9 @@ fn preserve_missing_agents(
|
|
|
398
473
|
slot.insert(latest_agent.clone());
|
|
399
474
|
}
|
|
400
475
|
serde_json::map::Entry::Occupied(mut existing) => {
|
|
401
|
-
|
|
476
|
+
if !skip_capture_backfill || !skip_capture_backfill_agent_ids.contains(agent_id) {
|
|
477
|
+
backfill_capture_fields(existing.get_mut(), latest_agent);
|
|
478
|
+
}
|
|
402
479
|
}
|
|
403
480
|
}
|
|
404
481
|
}
|
|
@@ -14,7 +14,7 @@ use std::path::Path;
|
|
|
14
14
|
use serde_json::{json, Map, Value};
|
|
15
15
|
|
|
16
16
|
use super::StateError;
|
|
17
|
-
use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents};
|
|
17
|
+
use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents, save_runtime_state_with_team_tombstoned_agents};
|
|
18
18
|
|
|
19
19
|
/// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
|
|
20
20
|
/// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
|
|
@@ -162,6 +162,17 @@ pub fn compact_team_state(state: &Value) -> Value {
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
pub fn state_is_external_leader(state: &Value) -> bool {
|
|
166
|
+
state
|
|
167
|
+
.get("is_external_leader")
|
|
168
|
+
.and_then(Value::as_bool)
|
|
169
|
+
.unwrap_or(false)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub fn state_is_managed_leader(state: &Value) -> bool {
|
|
173
|
+
!state_is_external_leader(state)
|
|
174
|
+
}
|
|
175
|
+
|
|
165
176
|
/// `merge_workspace_team_state`(`state.py:111`):把新启动的 team 并入既有 workspace state。
|
|
166
177
|
pub fn merge_workspace_team_state(existing: &Value, launched: &Value) -> Value {
|
|
167
178
|
let launched_key = team_state_key(launched);
|
|
@@ -471,6 +482,23 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
|
471
482
|
workspace: &Path,
|
|
472
483
|
team_state: &Value,
|
|
473
484
|
deleted_agent_ids: &[&str],
|
|
485
|
+
) -> Result<(), StateError> {
|
|
486
|
+
save_team_scoped_state_with_merge_exceptions(workspace, team_state, deleted_agent_ids, &[])
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
pub(crate) fn save_team_scoped_state_with_tombstoned_agents(
|
|
490
|
+
workspace: &Path,
|
|
491
|
+
team_state: &Value,
|
|
492
|
+
tombstoned_agent_ids: &[&str],
|
|
493
|
+
) -> Result<(), StateError> {
|
|
494
|
+
save_team_scoped_state_with_merge_exceptions(workspace, team_state, &[], tombstoned_agent_ids)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
fn save_team_scoped_state_with_merge_exceptions(
|
|
498
|
+
workspace: &Path,
|
|
499
|
+
team_state: &Value,
|
|
500
|
+
deleted_agent_ids: &[&str],
|
|
501
|
+
tombstoned_agent_ids: &[&str],
|
|
474
502
|
) -> Result<(), StateError> {
|
|
475
503
|
let target_key = team_state_key(team_state);
|
|
476
504
|
let existing = load_runtime_state(workspace)?;
|
|
@@ -500,7 +528,15 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
|
500
528
|
// not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
|
|
501
529
|
if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
|
|
502
530
|
let merged = compact_team_state(team_state);
|
|
503
|
-
|
|
531
|
+
if tombstoned_agent_ids.is_empty() {
|
|
532
|
+
return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
|
|
533
|
+
}
|
|
534
|
+
return save_runtime_state_with_team_tombstoned_agents(
|
|
535
|
+
workspace,
|
|
536
|
+
&merged,
|
|
537
|
+
&target_key,
|
|
538
|
+
tombstoned_agent_ids,
|
|
539
|
+
);
|
|
504
540
|
}
|
|
505
541
|
// teams = deepcopy(incoming_teams or existing_teams)
|
|
506
542
|
let mut teams = match incoming_teams {
|
|
@@ -520,7 +556,16 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
|
520
556
|
if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
|
|
521
557
|
merged.remove("teams");
|
|
522
558
|
}
|
|
523
|
-
|
|
559
|
+
if tombstoned_agent_ids.is_empty() {
|
|
560
|
+
save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
|
|
561
|
+
} else {
|
|
562
|
+
save_runtime_state_with_team_tombstoned_agents(
|
|
563
|
+
workspace,
|
|
564
|
+
&Value::Object(merged),
|
|
565
|
+
&target_key,
|
|
566
|
+
tombstoned_agent_ids,
|
|
567
|
+
)
|
|
568
|
+
}
|
|
524
569
|
}
|
|
525
570
|
|
|
526
571
|
// ---- helpers ----
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
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.3.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.3.18",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.18",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.18"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|