@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 CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.17"
569
+ version = "0.3.18"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.17"
12
+ version = "0.3.18"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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(report) => {
1726
- Ok(json!({"ok": true, "agent_id": agent, "report": format!("{report:?}")}))
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!("external")),
577
- "is_external_leader": full.get("is_external_leader").cloned().unwrap_or(Value::Bool(true)),
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 true;
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
- .unwrap_or("")
532
- .to_string();
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::save_team_scoped_state(workspace, &state)
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(workspace, agent_id, &discarded_session_id)?;
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, start_mode, ..
560
- } => Ok(ResetAgentOutcome::Reset { env, start_mode }),
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
- // Order remains the golden prefix (spec_path display_backend) + new suffix
2229
- // (active_team_key, teams). display_backend stays the resolved backend from
2230
- // display/backend.py:12-29 (default adaptive), not the raw optional spec field.
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 (active_team_key, teams). \
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 { env, start_mode } => Ok(ToolOk {
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
- save_runtime_state_with_deleted_agents(workspace, state, &[])
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
- preserve_latest_roster_entries(&mut migrated, &latest, &deleted);
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(incoming: &mut Value, latest: &Value, deleted_agent_ids: &BTreeSet<String>) {
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(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
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(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
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(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
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
- backfill_capture_fields(existing.get_mut(), latest_agent);
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
- return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
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
- save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
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.17",
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.17",
24
- "@team-agent/cli-darwin-x64": "0.3.17",
25
- "@team-agent/cli-linux-x64": "0.3.17"
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",