@team-agent/installer 0.3.1 → 0.3.3

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.
Files changed (79) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -20,7 +20,7 @@
20
20
  //! (`identity::migrate_state_identity`,补 leader_session_uuid)→ `_migrate_active_team_key`
21
21
  //! (seed active 指针);任一改动 → `save_runtime_state` 回写。不存在且命中缓存 → 返回缓存 deepcopy。
22
22
 
23
- use std::collections::HashMap;
23
+ use std::collections::{BTreeSet, HashMap};
24
24
  use std::io;
25
25
  use std::path::{Path, PathBuf};
26
26
  use std::sync::{LazyLock, Mutex};
@@ -170,6 +170,14 @@ 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, &[])
174
+ }
175
+
176
+ pub(crate) fn save_runtime_state_with_deleted_agents(
177
+ workspace: &Path,
178
+ state: &Value,
179
+ deleted_agent_ids: &[&str],
180
+ ) -> Result<(), StateError> {
173
181
  let path = runtime_state_path(workspace);
174
182
  if cache_equals(&path, state) {
175
183
  return Ok(());
@@ -203,6 +211,15 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
203
211
  if let Some(parent) = path.parent() {
204
212
  std::fs::create_dir_all(parent)?;
205
213
  }
214
+ if let Some(latest) = read_latest_state_under_lock(workspace, &path) {
215
+ let deleted = deleted_agent_ids
216
+ .iter()
217
+ .copied()
218
+ .filter(|id| !id.is_empty())
219
+ .map(str::to_string)
220
+ .collect::<BTreeSet<_>>();
221
+ preserve_latest_roster_entries(&mut migrated, &latest, &deleted);
222
+ }
206
223
  // 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
207
224
  let payload = serde_json::to_string_pretty(&migrated)?;
208
225
  let delays = [0.05_f64, 0.2, 0.5];
@@ -243,6 +260,158 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
243
260
  Err(StateError::SaveFailed("retry loop exhausted without return".to_string()))
244
261
  }
245
262
 
263
+ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value> {
264
+ let text = std::fs::read_to_string(path).ok()?;
265
+ let mut latest = serde_json::from_str::<Value>(&text).ok()?;
266
+ normalize_agent_session_state(&mut latest);
267
+ let _ = migrate_state_identity(&mut latest, &SystemEnv, workspace);
268
+ let _ = migrate_active_team_key(&mut latest);
269
+ Some(latest)
270
+ }
271
+
272
+ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_agent_ids: &BTreeSet<String>) {
273
+ if !same_runtime_projection(incoming, latest) {
274
+ return;
275
+ }
276
+ preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
277
+ preserve_latest_ownership_fields(incoming, latest);
278
+
279
+ let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
280
+ if let Some(active_team) = active_team.as_deref() {
281
+ let latest_active_agents = latest
282
+ .get("teams")
283
+ .and_then(Value::as_object)
284
+ .and_then(|teams| teams.get(active_team))
285
+ .and_then(|entry| entry.get("agents"));
286
+ preserve_missing_agents(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
287
+ }
288
+
289
+ let latest_teams = latest.get("teams").and_then(Value::as_object);
290
+ let Some(incoming_teams) = incoming.get_mut("teams").and_then(Value::as_object_mut) else {
291
+ return;
292
+ };
293
+ if let Some(latest_teams) = latest_teams {
294
+ for (team, latest_entry) in latest_teams {
295
+ let Some(incoming_entry) = incoming_teams.get_mut(team) else {
296
+ continue;
297
+ };
298
+ preserve_missing_agents(
299
+ incoming_entry.get_mut("agents"),
300
+ latest_entry.get("agents"),
301
+ deleted_agent_ids,
302
+ );
303
+ preserve_latest_ownership_fields(incoming_entry, latest_entry);
304
+ }
305
+ }
306
+ if let Some(active_team) = active_team.as_deref() {
307
+ let latest_top_agents = latest.get("agents");
308
+ if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
309
+ preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
310
+ preserve_latest_ownership_fields(incoming_entry, latest);
311
+ }
312
+ }
313
+ }
314
+
315
+ fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
316
+ if !latest_has_preferable_ownership(incoming, latest) {
317
+ return;
318
+ }
319
+ let Some(incoming_obj) = incoming.as_object_mut() else {
320
+ return;
321
+ };
322
+ for key in ["leader_receiver", "team_owner", "owner_epoch"] {
323
+ if let Some(value) = latest.get(key).filter(|value| json_truthy(value)) {
324
+ incoming_obj.insert(key.to_string(), value.clone());
325
+ }
326
+ }
327
+ }
328
+
329
+ fn latest_has_preferable_ownership(incoming: &Value, latest: &Value) -> bool {
330
+ let latest_epoch = ownership_epoch(latest);
331
+ let incoming_epoch = ownership_epoch(incoming);
332
+ if latest_epoch > incoming_epoch {
333
+ return true;
334
+ }
335
+ latest_epoch == incoming_epoch
336
+ && !ownership_attached(incoming)
337
+ && ownership_attached(latest)
338
+ }
339
+
340
+ fn ownership_epoch(state: &Value) -> u64 {
341
+ state
342
+ .get("owner_epoch")
343
+ .and_then(Value::as_u64)
344
+ .or_else(|| {
345
+ state
346
+ .get("team_owner")
347
+ .and_then(|owner| owner.get("owner_epoch"))
348
+ .and_then(Value::as_u64)
349
+ })
350
+ .or_else(|| {
351
+ state
352
+ .get("leader_receiver")
353
+ .and_then(|receiver| receiver.get("owner_epoch"))
354
+ .and_then(Value::as_u64)
355
+ })
356
+ .unwrap_or(0)
357
+ }
358
+
359
+ fn ownership_attached(state: &Value) -> bool {
360
+ ["leader_receiver", "team_owner"].into_iter().any(|key| {
361
+ state
362
+ .get(key)
363
+ .and_then(|value| value.get("pane_id"))
364
+ .and_then(Value::as_str)
365
+ .is_some_and(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
366
+ })
367
+ }
368
+
369
+ fn preserve_missing_agents(
370
+ incoming_agents: Option<&mut Value>,
371
+ latest_agents: Option<&Value>,
372
+ deleted_agent_ids: &BTreeSet<String>,
373
+ ) {
374
+ let Some(incoming_agents) = incoming_agents else {
375
+ return;
376
+ };
377
+ let Some(incoming_map) = incoming_agents.as_object_mut() else {
378
+ return;
379
+ };
380
+ let Some(latest_map) = latest_agents.and_then(Value::as_object) else {
381
+ return;
382
+ };
383
+ for (agent_id, latest_agent) in latest_map {
384
+ if deleted_agent_ids.contains(agent_id) {
385
+ continue;
386
+ }
387
+ incoming_map
388
+ .entry(agent_id.clone())
389
+ .or_insert_with(|| latest_agent.clone());
390
+ }
391
+ }
392
+
393
+ fn same_runtime_projection(left: &Value, right: &Value) -> bool {
394
+ let left_session = left.get("session_name").and_then(Value::as_str);
395
+ let right_session = right.get("session_name").and_then(Value::as_str);
396
+ if left_session.is_some() && right_session.is_some() && left_session != right_session {
397
+ return false;
398
+ }
399
+ let left_team = active_team_key(left);
400
+ let right_team = active_team_key(right);
401
+ if left_team.is_some() && right_team.is_some() && left_team != right_team {
402
+ return false;
403
+ }
404
+ true
405
+ }
406
+
407
+ fn active_team_key(state: &Value) -> Option<String> {
408
+ state
409
+ .get("active_team_key")
410
+ .and_then(Value::as_str)
411
+ .filter(|team| !team.is_empty() && *team != "current")
412
+ .map(str::to_string)
413
+ }
414
+
246
415
  /// `_self_heal_runtime_state`:重建 inode(heal-tmp + backup-rename),绝不 in-place truncate。
247
416
  fn self_heal(
248
417
  workspace: &Path,
@@ -14,11 +14,18 @@ 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};
17
+ use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_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"`。
21
21
  pub fn team_state_key(state: &Value) -> String {
22
+ if let Some(team_key) = state
23
+ .get("team_key")
24
+ .and_then(Value::as_str)
25
+ .filter(|key| !key.is_empty())
26
+ {
27
+ return team_key.to_string();
28
+ }
22
29
  for field in ["team_dir", "spec_path"] {
23
30
  // Python `if not value: continue` —— None/空串 falsy 跳过。
24
31
  let value = match state.get(field).and_then(Value::as_str) {
@@ -43,6 +50,105 @@ pub fn team_state_key(state: &Value) -> String {
43
50
  .map_or_else(|| "current".to_string(), str::to_string)
44
51
  }
45
52
 
53
+ #[derive(Debug, Clone, PartialEq, Eq)]
54
+ pub enum OwnerTeamResolution {
55
+ Canonical(String),
56
+ LegacyAlias { requested: String, canonical: String },
57
+ Unresolved { requested: String },
58
+ Ambiguous { requested: String, matches: Vec<String> },
59
+ }
60
+
61
+ impl OwnerTeamResolution {
62
+ pub fn canonical_key(&self) -> Option<&str> {
63
+ match self {
64
+ OwnerTeamResolution::Canonical(key)
65
+ | OwnerTeamResolution::LegacyAlias { canonical: key, .. } => Some(key),
66
+ OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => None,
67
+ }
68
+ }
69
+ }
70
+
71
+ pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamResolution {
72
+ let requested = owner_team_id.trim();
73
+ if requested.is_empty() {
74
+ return OwnerTeamResolution::Unresolved { requested: owner_team_id.to_string() };
75
+ }
76
+ let teams = state.get("teams").and_then(Value::as_object);
77
+ if teams.is_some_and(|teams| teams.contains_key(requested)) {
78
+ return OwnerTeamResolution::Canonical(requested.to_string());
79
+ }
80
+ if teams.is_none_or(Map::is_empty) {
81
+ let active = state.get("active_team_key").and_then(Value::as_str).unwrap_or("");
82
+ let derived = team_state_key(state);
83
+ if active == requested || derived == requested {
84
+ return OwnerTeamResolution::Canonical(requested.to_string());
85
+ }
86
+ if !active.is_empty() {
87
+ return OwnerTeamResolution::LegacyAlias {
88
+ requested: requested.to_string(),
89
+ canonical: active.to_string(),
90
+ };
91
+ }
92
+ if derived != "current" {
93
+ return OwnerTeamResolution::LegacyAlias {
94
+ requested: requested.to_string(),
95
+ canonical: derived,
96
+ };
97
+ }
98
+ return OwnerTeamResolution::Canonical(requested.to_string());
99
+ }
100
+ let Some(teams) = teams else {
101
+ return OwnerTeamResolution::Unresolved { requested: requested.to_string() };
102
+ };
103
+ let mut matches = Vec::new();
104
+ for (key, entry) in teams {
105
+ if legacy_owner_team_aliases(entry).any(|alias| alias == requested) {
106
+ matches.push(key.clone());
107
+ }
108
+ }
109
+ matches.sort();
110
+ matches.dedup();
111
+ match matches.len() {
112
+ 0 => OwnerTeamResolution::Unresolved { requested: requested.to_string() },
113
+ 1 => OwnerTeamResolution::LegacyAlias {
114
+ requested: requested.to_string(),
115
+ canonical: matches.remove(0),
116
+ },
117
+ _ => OwnerTeamResolution::Ambiguous { requested: requested.to_string(), matches },
118
+ }
119
+ }
120
+
121
+ fn legacy_owner_team_aliases(entry: &Value) -> impl Iterator<Item = String> + '_ {
122
+ let scalar_paths = [
123
+ "/team/name",
124
+ "/team/id",
125
+ "/name",
126
+ "/team_name",
127
+ "/team_id",
128
+ "/spec_name",
129
+ "/legacy_owner_team_id",
130
+ "/legacy_team_id",
131
+ "/legacy_team_name",
132
+ "/legacy_alias",
133
+ ];
134
+ let list_paths = ["/legacy_aliases", "/legacy_team_aliases", "/legacy_owner_team_ids", "/aliases"];
135
+ let scalars = scalar_paths
136
+ .into_iter()
137
+ .filter_map(|path| entry.pointer(path).and_then(Value::as_str));
138
+ let lists = list_paths.into_iter().flat_map(|path| {
139
+ entry
140
+ .pointer(path)
141
+ .and_then(Value::as_array)
142
+ .into_iter()
143
+ .flatten()
144
+ .filter_map(Value::as_str)
145
+ });
146
+ scalars
147
+ .chain(lists)
148
+ .filter(|alias| !alias.is_empty())
149
+ .map(str::to_string)
150
+ }
151
+
46
152
  /// `compact_team_state`(`state.py:105`):剔除 `teams`(team entry 不嵌套全量 teams),保序。
47
153
  pub fn compact_team_state(state: &Value) -> Value {
48
154
  match state.as_object() {
@@ -241,11 +347,7 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
241
347
  }
242
348
  let matches: Vec<&String> = alive
243
349
  .iter()
244
- .filter(|(key, value)| {
245
- let session = value.get("session_name").and_then(Value::as_str).unwrap_or("");
246
- let dir = value.get("team_dir").and_then(Value::as_str).unwrap_or("");
247
- team == key.as_str() || team == session || team == dir
248
- })
350
+ .filter(|(key, value)| team_selector_matches(team, key, value))
249
351
  .map(|(k, _)| k)
250
352
  .collect();
251
353
  if matches.len() == 1 {
@@ -282,6 +384,29 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
282
384
  ))
283
385
  }
284
386
 
387
+ fn team_selector_matches(team: &str, key: &str, value: &Value) -> bool {
388
+ if team == key {
389
+ return true;
390
+ }
391
+ let session = value.get("session_name").and_then(Value::as_str).unwrap_or("");
392
+ if team == session {
393
+ return true;
394
+ }
395
+ if let Some(stripped) = session.strip_prefix("team-") {
396
+ if team == stripped {
397
+ return true;
398
+ }
399
+ }
400
+ let dir = value.get("team_dir").and_then(Value::as_str).unwrap_or("");
401
+ if team == dir {
402
+ return true;
403
+ }
404
+ std::path::Path::new(dir)
405
+ .file_name()
406
+ .and_then(|name| name.to_str())
407
+ .is_some_and(|name| team == name)
408
+ }
409
+
285
410
  /// `ambiguous_team_target_result`(`state.py:226`):无显式 team 且多候选 → 拒绝 dict;否则 None。
286
411
  pub fn ambiguous_team_target_result(state: &Value) -> Option<Value> {
287
412
  let alive = team_state_candidates(state);
@@ -339,6 +464,14 @@ pub fn resolve_team_scoped_state(
339
464
  /// 纯 `save_runtime_state`(字节等价);多 team 时把本 team 落到 `teams[target_key]=compact(...)`,顶层
340
465
  /// 视图按 golden 的 `existing_primary_key` 逻辑择 incoming/existing。§10:无 unwrap/panic。
341
466
  pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<(), StateError> {
467
+ save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
468
+ }
469
+
470
+ pub(crate) fn save_team_scoped_state_with_deleted_agents(
471
+ workspace: &Path,
472
+ team_state: &Value,
473
+ deleted_agent_ids: &[&str],
474
+ ) -> Result<(), StateError> {
342
475
  let target_key = team_state_key(team_state);
343
476
  let existing = load_runtime_state(workspace)?;
344
477
  // existing_primary_key = team_state_key(existing) if existing.get("session_name") else None
@@ -367,7 +500,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
367
500
  // not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
368
501
  if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
369
502
  let merged = compact_team_state(team_state);
370
- return save_runtime_state(workspace, &merged);
503
+ return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
371
504
  }
372
505
  // teams = deepcopy(incoming_teams or existing_teams)
373
506
  let mut teams = match incoming_teams {
@@ -387,7 +520,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
387
520
  if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
388
521
  merged.remove("teams");
389
522
  }
390
- save_runtime_state(workspace, &Value::Object(merged))
523
+ save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
391
524
  }
392
525
 
393
526
  // ---- helpers ----
@@ -98,8 +98,11 @@ fn spec_workspace_from_state(state: &Value) -> Option<PathBuf> {
98
98
  }
99
99
 
100
100
  fn selected_team_key(state: &Value, team: Option<&str>) -> String {
101
- team.filter(|s| !s.is_empty())
101
+ state
102
+ .get("active_team_key")
103
+ .and_then(Value::as_str)
104
+ .filter(|s| !s.is_empty())
102
105
  .map(ToString::to_string)
103
- .or_else(|| state.get("active_team_key").and_then(Value::as_str).filter(|s| !s.is_empty()).map(ToString::to_string))
106
+ .or_else(|| team.filter(|s| !s.is_empty()).map(ToString::to_string))
104
107
  .unwrap_or_else(|| team_state_key(state))
105
108
  }