@team-agent/installer 0.5.51 → 0.5.53

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 +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +17 -0
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/named_address.rs +35 -67
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +11 -0
  10. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  11. package/crates/team-agent/src/kill_audit.rs +67 -0
  12. package/crates/team-agent/src/leader/lease.rs +526 -73
  13. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  14. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  15. package/crates/team-agent/src/leader/start.rs +144 -23
  16. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  17. package/crates/team-agent/src/leader/types.rs +6 -0
  18. package/crates/team-agent/src/lib.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  20. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
  21. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  24. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  26. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  29. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  30. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  32. package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
  33. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  34. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
  35. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  36. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
  38. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  41. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  42. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  43. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  44. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  45. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  46. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  47. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  48. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  49. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  50. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  51. package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
  52. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  53. package/crates/team-agent/src/messaging/mod.rs +5 -0
  54. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  55. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  56. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  57. package/crates/team-agent/src/provider/adapter.rs +54 -13
  58. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  59. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  60. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  61. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  62. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  63. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  64. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  65. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  66. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  67. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  68. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  69. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  70. package/crates/team-agent/src/provider/tests.rs +1 -0
  71. package/crates/team-agent/src/state/persist.rs +282 -20
  72. package/crates/team-agent/src/state/projection.rs +245 -30
  73. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  74. package/crates/team-agent/src/state/repository.rs +36 -12
  75. package/crates/team-agent/src/state/selector.rs +9 -18
  76. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  77. package/crates/team-agent/src/tmux_backend.rs +29 -2
  78. package/package.json +4 -4
  79. package/skills/team-agent/SKILL.md +8 -3
@@ -22,6 +22,8 @@ use crate::state::persist::{
22
22
  save_runtime_state_with_team_tombstoned_agents,
23
23
  };
24
24
 
25
+ pub(crate) const CURRENT_TEAM_ALIAS: &str = "current";
26
+
25
27
  /// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
26
28
  /// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
27
29
  pub fn team_state_key(state: &Value) -> String {
@@ -283,6 +285,14 @@ pub(crate) fn team_is_alive_candidate(team: &Value) -> bool {
283
285
  if let Some(status) = status_raw {
284
286
  return status.eq_ignore_ascii_case("alive");
285
287
  }
288
+ if !team
289
+ .get("agents")
290
+ .and_then(Value::as_object)
291
+ .is_some_and(|agents| !agents.is_empty())
292
+ && team_has_live_leader_binding(team)
293
+ {
294
+ return false;
295
+ }
286
296
  if all_present_agents_terminal(team) && !team_has_live_leader_binding(team) {
287
297
  return false;
288
298
  }
@@ -302,6 +312,20 @@ fn team_has_live_leader_binding(team: &Value) -> bool {
302
312
  is_non_null_object("leader_receiver") || is_non_null_object("team_owner")
303
313
  }
304
314
 
315
+ fn team_has_attached_leader_receiver(team: &Value) -> bool {
316
+ let Some(receiver) = team.get("leader_receiver").and_then(Value::as_object) else {
317
+ return false;
318
+ };
319
+ receiver
320
+ .get("status")
321
+ .and_then(Value::as_str)
322
+ .is_some_and(|status| status.eq_ignore_ascii_case("attached"))
323
+ && receiver
324
+ .get("pane_id")
325
+ .and_then(Value::as_str)
326
+ .is_some_and(|pane| !pane.is_empty())
327
+ }
328
+
305
329
  fn all_present_agents_terminal(team: &Value) -> bool {
306
330
  let Some(agents) = team.get("agents").and_then(Value::as_object) else {
307
331
  return false;
@@ -458,15 +482,71 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
458
482
  Value::Object(p)
459
483
  }
460
484
 
461
- /// `select_runtime_state`(`state.py:193`):读 state → 按 `team` 选候选 → 投影顶层视图。
462
- /// 歧义/未找到 `Err(StateError::TeamSelect(msg))`(msg == Python `str(RuntimeError)`)。
463
- pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Value, StateError> {
485
+ #[derive(Debug, Clone)]
486
+ pub struct TeamScopeResolution {
487
+ pub canonical_team_key: String,
488
+ pub state: Value,
489
+ }
490
+
491
+ /// Resolve a requested team selector once, returning both the canonical map key and its
492
+ /// projected state. Callers must carry `canonical_team_key` forward rather than deriving team
493
+ /// identity again from the projection or the original alias.
494
+ pub fn resolve_runtime_team_scope(
495
+ workspace: &Path,
496
+ team: Option<&str>,
497
+ ) -> Result<TeamScopeResolution, StateError> {
464
498
  let state = load_runtime_state(workspace)?;
465
499
  let alive = team_state_candidates(&state);
466
500
  // Python `if team:` —— 空串 falsy,等同无 team(对抗 P1:此前 Some("") 误入 team 分支,空
467
501
  // team_dir 匹配致歧义/未找到错误串漂移)。
468
502
  let team = team.filter(|t| !t.is_empty());
469
503
  if let Some(team) = team {
504
+ // Intentional semantic split with `persist::apply_persist_merge_contract`:
505
+ // selector resolution validates the active team against the alive/terminal
506
+ // view, while the lock-held merge cannot depend on that mutable view. A
507
+ // change to either CURRENT_TEAM_ALIAS mapping must evaluate the other.
508
+ let canonical_request = if team.eq_ignore_ascii_case(CURRENT_TEAM_ALIAS) {
509
+ let teams = state.get("teams").and_then(Value::as_object);
510
+ state
511
+ .get("active_team_key")
512
+ .and_then(Value::as_str)
513
+ .filter(|active| {
514
+ alive.contains_key(*active)
515
+ || teams.is_none_or(Map::is_empty)
516
+ || teams
517
+ .and_then(|teams| teams.get(*active))
518
+ .is_some_and(team_has_attached_leader_receiver)
519
+ })
520
+ .map(str::to_string)
521
+ .or_else(|| {
522
+ teams
523
+ .is_none_or(Map::is_empty)
524
+ .then(|| team_state_key(&state))
525
+ .filter(|derived| derived != "current")
526
+ })
527
+ .ok_or_else(|| {
528
+ StateError::TeamSelect(format!(
529
+ "team 'current' not found among alive teams. Use --team <team-key> with an explicit team key to address a terminal team. {}",
530
+ format_team_candidates(&alive)
531
+ ))
532
+ })?
533
+ } else {
534
+ team.to_string()
535
+ };
536
+ let team = canonical_request.as_str();
537
+ // An exact canonical key is authoritative even when the team is terminal or excluded from
538
+ // the default alive-candidate set. Explicit lifecycle operations must still be able to
539
+ // address a shutdown team without falling back to the raw root projection.
540
+ if state
541
+ .get("teams")
542
+ .and_then(Value::as_object)
543
+ .is_some_and(|teams| teams.contains_key(team))
544
+ {
545
+ return Ok(TeamScopeResolution {
546
+ canonical_team_key: team.to_string(),
547
+ state: project_top_level_view(&state, team),
548
+ });
549
+ }
470
550
  // 无 alive 但 team 命中 active_team_key / 派生 key → 直接以全量 state 投影。
471
551
  if alive.is_empty() {
472
552
  let active = state
@@ -484,32 +564,22 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
484
564
  }
485
565
  None => projection = json!({ "active_team_key": team }),
486
566
  }
487
- return Ok(projection);
567
+ return Ok(TeamScopeResolution {
568
+ canonical_team_key: team.to_string(),
569
+ state: projection,
570
+ });
488
571
  }
489
572
  }
490
- if alive.contains_key(team) {
491
- return Ok(project_top_level_view(&state, team));
492
- }
493
- // 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.1):
494
- // 显式 `--team X` 命中 `state.teams` 里的键(即使被标 shutdown/终态)
495
- // 仍应投影出来,支持「shutdown 后再 restart --team X」多队场景
496
- // (E2E-REST-014 家族)。走 project_top_level_view 保证顶层 agents
497
- // 与 teams[key].agents 收敛(E2E-REST-002 依赖 projection clobber
498
- // 顶层注入的 session_id)。
499
- if state
500
- .get("teams")
501
- .and_then(Value::as_object)
502
- .is_some_and(|teams| teams.contains_key(team))
503
- {
504
- return Ok(project_top_level_view(&state, team));
505
- }
506
573
  let matches: Vec<&String> = alive
507
574
  .iter()
508
575
  .filter(|(key, value)| team_selector_matches(team, key, value))
509
576
  .map(|(k, _)| k)
510
577
  .collect();
511
578
  if matches.len() == 1 {
512
- return Ok(project_top_level_view(&state, matches[0]));
579
+ return Ok(TeamScopeResolution {
580
+ canonical_team_key: matches[0].clone(),
581
+ state: project_top_level_view(&state, matches[0]),
582
+ });
513
583
  }
514
584
  if matches.len() > 1 {
515
585
  return Err(StateError::TeamSelect(
@@ -529,12 +599,18 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
529
599
  .filter(|s| !s.is_empty());
530
600
  if let Some(active) = active {
531
601
  if alive.contains_key(active) {
532
- return Ok(project_top_level_view(&state, active));
602
+ return Ok(TeamScopeResolution {
603
+ canonical_team_key: active.to_string(),
604
+ state: project_top_level_view(&state, active),
605
+ });
533
606
  }
534
607
  }
535
608
  if alive.len() == 1 {
536
609
  let only = alive.keys().next().cloned().unwrap_or_default();
537
- return Ok(project_top_level_view(&state, &only));
610
+ return Ok(TeamScopeResolution {
611
+ canonical_team_key: only.clone(),
612
+ state: project_top_level_view(&state, &only),
613
+ });
538
614
  }
539
615
  if alive.is_empty() {
540
616
  // 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.1):
@@ -547,10 +623,22 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
547
623
  .and_then(Value::as_object)
548
624
  .is_some_and(|teams| teams.contains_key(active_key))
549
625
  {
550
- return Ok(project_top_level_view(&state, active_key));
626
+ return Ok(TeamScopeResolution {
627
+ canonical_team_key: active_key.to_string(),
628
+ state: project_top_level_view(&state, active_key),
629
+ });
551
630
  }
552
631
  }
553
- return Ok(state);
632
+ let canonical_team_key = state
633
+ .get("active_team_key")
634
+ .and_then(Value::as_str)
635
+ .filter(|key| !key.is_empty())
636
+ .map(str::to_string)
637
+ .unwrap_or_else(|| team_state_key(&state));
638
+ return Ok(TeamScopeResolution {
639
+ canonical_team_key,
640
+ state,
641
+ });
554
642
  }
555
643
  Err(StateError::TeamSelect(
556
644
  "multiple teams found in this workspace; pass --team <team> to choose. ".to_string()
@@ -558,6 +646,11 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
558
646
  ))
559
647
  }
560
648
 
649
+ /// `select_runtime_state`(`state.py:193`):compatibility projection wrapper.
650
+ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Value, StateError> {
651
+ resolve_runtime_team_scope(workspace, team).map(|resolved| resolved.state)
652
+ }
653
+
561
654
  fn team_selector_matches(team: &str, key: &str, value: &Value) -> bool {
562
655
  if team == key {
563
656
  return true;
@@ -678,22 +771,41 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
678
771
  pub(crate) fn save_team_scoped_state_with_tombstone_lifecycle_topology_authority(
679
772
  workspace: &Path,
680
773
  team_state: &Value,
774
+ topology_team_key: &str,
681
775
  agent_ids: &[&str],
682
776
  ) -> Result<(), StateError> {
683
- save_team_scoped_state_with_merge_options(workspace, team_state, &[], agent_ids, &[], agent_ids)
777
+ save_team_scoped_state_with_merge_options(
778
+ workspace,
779
+ team_state,
780
+ &[],
781
+ agent_ids,
782
+ &[],
783
+ Some(topology_team_key),
784
+ agent_ids,
785
+ )
684
786
  }
685
787
 
686
788
  pub(crate) fn save_team_scoped_state_with_lifecycle_topology_authority(
687
789
  workspace: &Path,
688
790
  team_state: &Value,
791
+ topology_team_key: &str,
689
792
  agent_ids: &[&str],
690
793
  ) -> Result<(), StateError> {
691
- save_team_scoped_state_with_merge_options(workspace, team_state, &[], &[], &[], agent_ids)
794
+ save_team_scoped_state_with_merge_options(
795
+ workspace,
796
+ team_state,
797
+ &[],
798
+ &[],
799
+ &[],
800
+ Some(topology_team_key),
801
+ agent_ids,
802
+ )
692
803
  }
693
804
 
694
805
  pub(crate) fn save_team_scoped_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
695
806
  workspace: &Path,
696
807
  team_state: &Value,
808
+ topology_team_key: &str,
697
809
  skip_capture_backfill_agent_ids: &[&str],
698
810
  topology_agent_ids: &[&str],
699
811
  ) -> Result<(), StateError> {
@@ -703,6 +815,7 @@ pub(crate) fn save_team_scoped_state_with_lifecycle_topology_authority_and_captu
703
815
  &[],
704
816
  &[],
705
817
  skip_capture_backfill_agent_ids,
818
+ Some(topology_team_key),
706
819
  topology_agent_ids,
707
820
  )
708
821
  }
@@ -719,6 +832,7 @@ fn save_team_scoped_state_with_merge_exceptions(
719
832
  deleted_agent_ids,
720
833
  tombstoned_agent_ids,
721
834
  &[],
835
+ None,
722
836
  &[],
723
837
  )
724
838
  }
@@ -729,6 +843,7 @@ fn save_team_scoped_state_with_merge_options(
729
843
  deleted_agent_ids: &[&str],
730
844
  tombstoned_agent_ids: &[&str],
731
845
  skip_capture_backfill_agent_ids: &[&str],
846
+ topology_team_key: Option<&str>,
732
847
  topology_agent_ids: &[&str],
733
848
  ) -> Result<(), StateError> {
734
849
  let target_key = team_state_key(team_state);
@@ -768,7 +883,7 @@ fn save_team_scoped_state_with_merge_options(
768
883
  return save_runtime_state_with_team_tombstone_lifecycle_topology_authority(
769
884
  workspace,
770
885
  &merged,
771
- &target_key,
886
+ topology_team_key.unwrap_or(&target_key),
772
887
  topology_agent_ids,
773
888
  );
774
889
  }
@@ -776,7 +891,7 @@ fn save_team_scoped_state_with_merge_options(
776
891
  return save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
777
892
  workspace,
778
893
  &merged,
779
- &target_key,
894
+ topology_team_key.unwrap_or(&target_key),
780
895
  skip_capture_backfill_agent_ids,
781
896
  topology_agent_ids,
782
897
  );
@@ -784,6 +899,7 @@ fn save_team_scoped_state_with_merge_options(
784
899
  return save_runtime_state_with_lifecycle_topology_authority(
785
900
  workspace,
786
901
  &merged,
902
+ topology_team_key.unwrap_or(&target_key),
787
903
  topology_agent_ids,
788
904
  );
789
905
  }
@@ -834,7 +950,7 @@ fn save_team_scoped_state_with_merge_options(
834
950
  save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
835
951
  workspace,
836
952
  &Value::Object(merged),
837
- &target_key,
953
+ topology_team_key.unwrap_or(&target_key),
838
954
  skip_capture_backfill_agent_ids,
839
955
  topology_agent_ids,
840
956
  )
@@ -842,6 +958,7 @@ fn save_team_scoped_state_with_merge_options(
842
958
  save_runtime_state_with_lifecycle_topology_authority(
843
959
  workspace,
844
960
  &Value::Object(merged),
961
+ topology_team_key.unwrap_or(&target_key),
845
962
  topology_agent_ids,
846
963
  )
847
964
  }
@@ -1113,6 +1230,104 @@ mod tests {
1113
1230
  );
1114
1231
  }
1115
1232
 
1233
+ #[test]
1234
+ fn resolve_runtime_team_scope_current_uses_valid_active_key() {
1235
+ let ws = temp_ws_with_state(&json!({
1236
+ "active_team_key": "alpha",
1237
+ "teams": {
1238
+ "alpha": {"status": "alive", "session_name": "team-alpha", "agents": {"worker": {}}},
1239
+ "old-team": {"team_owner": {"pane_id": "%9"}}
1240
+ }
1241
+ }));
1242
+ let resolved = resolve_runtime_team_scope(&ws, Some("current")).unwrap();
1243
+ assert_eq!(resolved.canonical_team_key, "alpha");
1244
+ assert_eq!(
1245
+ resolved.state.get("active_team_key").and_then(Value::as_str),
1246
+ Some("alpha")
1247
+ );
1248
+ }
1249
+
1250
+ #[test]
1251
+ fn resolve_runtime_team_scope_current_accepts_attached_receiver_recovery() {
1252
+ let ws = temp_ws_with_state(&json!({
1253
+ "active_team_key": "alpha",
1254
+ "teams": {
1255
+ "alpha": {
1256
+ "agents": {},
1257
+ "leader_receiver": {"status": "attached", "pane_id": "%9"}
1258
+ }
1259
+ }
1260
+ }));
1261
+ let resolved = resolve_runtime_team_scope(&ws, Some("current")).unwrap();
1262
+ assert_eq!(resolved.canonical_team_key, "alpha");
1263
+ }
1264
+
1265
+ #[test]
1266
+ fn resolve_runtime_team_scope_current_accepts_shutdown_receiver_recovery() {
1267
+ let ws = temp_ws_with_state(&json!({
1268
+ "active_team_key": "alpha",
1269
+ "teams": {
1270
+ "alpha": {
1271
+ "status": "shutdown",
1272
+ "agents": {"worker": {"status": "stopped"}},
1273
+ "leader_receiver": {"status": "attached", "pane_id": "%9"}
1274
+ }
1275
+ }
1276
+ }));
1277
+ let resolved = resolve_runtime_team_scope(&ws, Some("current")).unwrap();
1278
+ assert_eq!(resolved.canonical_team_key, "alpha");
1279
+ }
1280
+
1281
+ #[test]
1282
+ fn resolve_runtime_team_scope_current_rejects_owner_only_stub() {
1283
+ let ws = temp_ws_with_state(&json!({
1284
+ "active_team_key": "old-team",
1285
+ "teams": {
1286
+ "old-team": {"agents": {}, "team_owner": {"pane_id": "%9"}}
1287
+ }
1288
+ }));
1289
+ let error = resolve_runtime_team_scope(&ws, Some("current")).unwrap_err();
1290
+ assert!(error.to_string().contains("team 'current' not found"));
1291
+ }
1292
+
1293
+ #[test]
1294
+ fn resolve_runtime_team_scope_current_fails_without_valid_active_key() {
1295
+ let ws = temp_ws_with_state(&json!({
1296
+ "active_team_key": "missing",
1297
+ "teams": {
1298
+ "alpha": {"status": "alive", "session_name": "team-alpha", "agents": {"worker": {}}}
1299
+ }
1300
+ }));
1301
+ let error = resolve_runtime_team_scope(&ws, Some("current")).unwrap_err();
1302
+ assert!(error.to_string().contains("team 'current' not found"));
1303
+ }
1304
+
1305
+ #[test]
1306
+ fn resolve_runtime_team_scope_current_keeps_legacy_single_team_state() {
1307
+ let ws = temp_ws_with_state(&json!({
1308
+ "active_team_key": "legacy",
1309
+ "team_key": "legacy",
1310
+ "session_name": "team-legacy",
1311
+ "agents": {"worker": {"status": "running"}}
1312
+ }));
1313
+ let resolved = resolve_runtime_team_scope(&ws, Some("current")).unwrap();
1314
+ assert_eq!(resolved.canonical_team_key, "legacy");
1315
+ assert_eq!(resolved.state["active_team_key"], json!("legacy"));
1316
+ }
1317
+
1318
+ #[test]
1319
+ fn resolve_runtime_team_scope_current_rejects_empty_workspace_bootstrap() {
1320
+ let ws = temp_ws_with_state(&json!({}));
1321
+ let error = resolve_runtime_team_scope(&ws, Some("current")).unwrap_err();
1322
+ assert!(error.to_string().contains("team 'current' not found"));
1323
+ let saved = load_runtime_state(&ws).unwrap();
1324
+ assert_ne!(
1325
+ saved.get("active_team_key").and_then(Value::as_str),
1326
+ Some("current")
1327
+ );
1328
+ assert!(!crate::model::paths::runtime_spec_path(&ws, "current").exists());
1329
+ }
1330
+
1116
1331
  // 对抗 P1:空串 team 必须走「无 team」分支(Python `if team:` falsy),报「multiple teams」
1117
1332
  // 而非「team selector is ambiguous」(后者是非空 team 多匹配)。
1118
1333
  #[test]
@@ -0,0 +1,13 @@
1
+ use super::StateWriteIntent;
2
+
3
+ impl<'a> StateWriteIntent<'a> {
4
+ pub(crate) fn launch_team_or_add_agent(
5
+ team_key: &'a str,
6
+ added_agent_id: Option<&'a str>,
7
+ ) -> Self {
8
+ match added_agent_id {
9
+ Some(agent_id) => Self::AddAgent { team_key, agent_id },
10
+ None => Self::LaunchTeam { team_key },
11
+ }
12
+ }
13
+ }
@@ -46,6 +46,7 @@ use super::persist::{
46
46
  save_runtime_state_with_deleted_agents as helper_write_root_with_deleted_agents,
47
47
  save_runtime_state_with_lifecycle_topology_authority as helper_write_root_with_lifecycle_topology_authority,
48
48
  save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip as helper_write_root_with_lifecycle_topology_authority_and_capture_backfill_skip,
49
+ save_runtime_state_with_receiver_authority as helper_write_root_with_receiver_authority,
49
50
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
50
51
  save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
51
52
  save_runtime_state_without_migrations as helper_write_root_without_migrations,
@@ -60,6 +61,8 @@ use super::projection::{
60
61
  save_team_scoped_state_with_tombstone_lifecycle_topology_authority as helper_write_team_scoped_with_tombstone_lifecycle_topology_authority,
61
62
  };
62
63
 
64
+ mod intent;
65
+
63
66
  /// Repository facade for workspace state reads and writes.
64
67
  ///
65
68
  /// S1a scope: hold the workspace path, expose `load_workspace`/`load_team`
@@ -141,6 +144,10 @@ pub enum StateWriteIntent<'a> {
141
144
  LaunchTeam {
142
145
  team_key: &'a str,
143
146
  },
147
+ AddAgent {
148
+ team_key: &'a str,
149
+ agent_id: &'a str,
150
+ },
144
151
  AnnotateTeamDepth {
145
152
  team_key: &'a str,
146
153
  },
@@ -245,9 +252,8 @@ pub enum StateWriteIntent<'a> {
245
252
  SelfMigration,
246
253
  }
247
254
 
248
- // S1a dispatch: every intent forwards to exactly the same helper family the
249
- // pre-S1a caller used, so behavior is bit-identical and only the naming has
250
- // shifted. When S1b migrates a writer cluster, only the arms below change;
255
+ // S1a dispatch: every intent forwards to the same pre-S1a helper family; only naming shifted.
256
+ // When S1b migrates a writer cluster, only the arms below change;
251
257
  // the callsites and tests keep talking to `StateRepository`.
252
258
  fn route_direct(
253
259
  workspace: &Path,
@@ -258,6 +264,14 @@ fn route_direct(
258
264
  // LaunchTeam -> the launch writer path uses the root helper today
259
265
  // (`save_runtime_state` at lifecycle/launch.rs:996).
260
266
  StateWriteIntent::LaunchTeam { .. } => helper_write_root(workspace, state),
267
+ StateWriteIntent::AddAgent { team_key, agent_id } => {
268
+ helper_write_team_scoped_with_lifecycle_topology_authority(
269
+ workspace,
270
+ state,
271
+ team_key,
272
+ &[agent_id],
273
+ )
274
+ }
261
275
  // AnnotateTeamDepth also lives on the root save path.
262
276
  StateWriteIntent::AnnotateTeamDepth { .. } => helper_write_root(workspace, state),
263
277
  // ShutdownTeam: scoped clean shutdown uses `save_team_scoped_state`;
@@ -277,12 +291,13 @@ fn route_direct(
277
291
  // `save_team_scoped_state_with_lifecycle_topology_authority_and_capture_backfill_skip`
278
292
  // at lifecycle/restart/common.rs:633.
279
293
  StateWriteIntent::RestartTeam {
294
+ team_key,
280
295
  topology_authority_agent_ids,
281
296
  skip_capture_backfill_agent_ids,
282
- ..
283
297
  } => helper_write_team_scoped_with_lifecycle_topology_authority_and_capture_backfill_skip(
284
298
  workspace,
285
299
  state,
300
+ team_key,
286
301
  skip_capture_backfill_agent_ids,
287
302
  topology_authority_agent_ids,
288
303
  ),
@@ -292,16 +307,17 @@ fn route_direct(
292
307
  StateWriteIntent::RestartSessionRepair { .. } => helper_write_team_scoped(workspace, state),
293
308
  // StartAgent - the launch add-agent tail lands via
294
309
  // `save_team_scoped_state_with_lifecycle_topology_authority` today.
295
- StateWriteIntent::StartAgent { .. } => {
296
- helper_write_team_scoped_with_lifecycle_topology_authority(workspace, state, &[])
310
+ StateWriteIntent::StartAgent { team_key, .. } => {
311
+ helper_write_team_scoped_with_lifecycle_topology_authority(workspace, state, team_key, &[])
297
312
  }
298
313
  // StopAgent uses the legacy helper family
299
314
  // `save_team_scoped_state_with_lifecycle_topology_authority`,
300
315
  // matching lifecycle/restart/agent.rs:911.
301
- StateWriteIntent::StopAgent { agent_id, .. } => {
316
+ StateWriteIntent::StopAgent { team_key, agent_id } => {
302
317
  helper_write_team_scoped_with_lifecycle_topology_authority(
303
318
  workspace,
304
319
  state,
320
+ team_key,
305
321
  &[agent_id],
306
322
  )
307
323
  }
@@ -310,20 +326,22 @@ fn route_direct(
310
326
  // matching lifecycle/restart/agent.rs:1355. Non-discard falls back to
311
327
  // the same lifecycle topology-authority helper as StopAgent.
312
328
  StateWriteIntent::ResetAgent {
329
+ team_key,
313
330
  agent_id,
314
331
  discard_session,
315
- ..
316
332
  } => {
317
333
  if discard_session {
318
334
  helper_write_team_scoped_with_tombstone_lifecycle_topology_authority(
319
335
  workspace,
320
336
  state,
337
+ team_key,
321
338
  &[agent_id],
322
339
  )
323
340
  } else {
324
341
  helper_write_team_scoped_with_lifecycle_topology_authority(
325
342
  workspace,
326
343
  state,
344
+ team_key,
327
345
  &[agent_id],
328
346
  )
329
347
  }
@@ -335,10 +353,11 @@ fn route_direct(
335
353
  }
336
354
  // ForkAgent mutates a selected team projection, so persist it back to
337
355
  // that projection with the forked row as lifecycle topology authority.
338
- StateWriteIntent::ForkAgent { agent_id, .. } => {
356
+ StateWriteIntent::ForkAgent { team_key, agent_id } => {
339
357
  helper_write_team_scoped_with_lifecycle_topology_authority(
340
358
  workspace,
341
359
  state,
360
+ team_key,
342
361
  &[agent_id],
343
362
  )
344
363
  }
@@ -352,23 +371,28 @@ fn route_direct(
352
371
  // Force-recreate rollback restores an existing row after the
353
372
  // replacement spawn has advanced its lifecycle tuple. The selected
354
373
  // row is therefore the explicit topology authority.
355
- StateWriteIntent::ForceRecreateRollback { agent_id, .. } => {
374
+ StateWriteIntent::ForceRecreateRollback { team_key, agent_id } => {
356
375
  helper_write_team_scoped_with_lifecycle_topology_authority(
357
376
  workspace,
358
377
  state,
378
+ team_key,
359
379
  &[agent_id],
360
380
  )
361
381
  }
362
382
  // ClaimLeader -> leader/lease.rs:1625 uses the root helper, and the
363
383
  // scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
364
384
  // agents helper.
365
- StateWriteIntent::ClaimLeader { .. } => helper_write_root(workspace, state),
385
+ StateWriteIntent::ClaimLeader { team_key } => {
386
+ helper_write_root_with_receiver_authority(workspace, state, team_key)
387
+ }
366
388
  StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
367
389
  helper_write_root_without_migrations(workspace, state)
368
390
  }
369
391
  // LeaderStartBinding -> managed/exec/external all root-save at
370
392
  // leader/start.rs:795/903/946.
371
- StateWriteIntent::LeaderStartBinding { .. } => helper_write_root(workspace, state),
393
+ StateWriteIntent::LeaderStartBinding { team_key, .. } => {
394
+ helper_write_root_with_receiver_authority(workspace, state, team_key)
395
+ }
372
396
  // CoordinatorTick dispatches to the existing scoped helper
373
397
  // (`save_team_scoped_state`) preserving coordinator/tick.rs:427.
374
398
  StateWriteIntent::CoordinatorTick { .. } => helper_write_team_scoped(workspace, state),
@@ -5,8 +5,8 @@ use std::path::{Path, PathBuf};
5
5
  use serde_json::Value;
6
6
 
7
7
  use crate::model::paths::{canonical_run_workspace, runtime_spec_path, team_workspace};
8
- use crate::state::persist::{load_runtime_state, runtime_state_path};
9
- use crate::state::projection::{select_runtime_state, team_state_key};
8
+ use crate::state::persist::runtime_state_path;
9
+ use crate::state::projection::resolve_runtime_team_scope;
10
10
  use crate::state::StateError;
11
11
 
12
12
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -36,15 +36,15 @@ pub fn resolve_active_team(
36
36
  mode: SelectorMode,
37
37
  ) -> Result<SelectedTeam, StateError> {
38
38
  let explicit_spec = input.join("team.spec.yaml");
39
- let (run_workspace, state) = if explicit_spec.exists() {
39
+ let (run_workspace, resolved) = if explicit_spec.exists() {
40
40
  let team_run = team_workspace(input).map_err(|e| StateError::TeamSelect(e.to_string()))?;
41
41
  let run = if runtime_state_path(input).exists() || !runtime_state_path(&team_run).exists() {
42
42
  input.to_path_buf()
43
43
  } else {
44
44
  team_run
45
45
  };
46
- let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
47
- (run, state)
46
+ let resolved = resolve_runtime_team_scope(&run, team)?;
47
+ (run, resolved)
48
48
  } else {
49
49
  let run =
50
50
  canonical_run_workspace(input).map_err(|e| StateError::TeamSelect(e.to_string()))?;
@@ -58,15 +58,16 @@ pub fn resolve_active_team(
58
58
  input.display()
59
59
  )));
60
60
  }
61
- let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
62
- (run, state)
61
+ let resolved = resolve_runtime_team_scope(&run, team)?;
62
+ (run, resolved)
63
63
  };
64
+ let state = resolved.state;
64
65
 
65
66
  // E5 spec 迁移·读序 B(architect+leader 裁定):
66
67
  // 1) runtime spec 优先严格:<run_ws>/.team/runtime/<team_key>/team.spec.yaml 存在即必用。
67
68
  // 2) 缺失才**只读回落**用户目录旧 spec(过渡腿;绝不在此写/迁移——迁移+清理只属启动重建)。
68
69
  // TODO(E5 后续版本):新 team 永不写用户目录(G1),回落腿可在 legacy 清零后移除。
69
- let team_key = selected_team_key(&state, team);
70
+ let team_key = resolved.canonical_team_key;
70
71
  let runtime_spec = runtime_spec_path(&run_workspace, &team_key);
71
72
  let (spec_workspace, spec_path) = if runtime_spec.exists() {
72
73
  (
@@ -136,13 +137,3 @@ fn spec_workspace_from_state(state: &Value) -> Option<PathBuf> {
136
137
  .map(PathBuf::from)
137
138
  })
138
139
  }
139
-
140
- fn selected_team_key(state: &Value, team: Option<&str>) -> String {
141
- state
142
- .get("active_team_key")
143
- .and_then(Value::as_str)
144
- .filter(|s| !s.is_empty())
145
- .map(ToString::to_string)
146
- .or_else(|| team.filter(|s| !s.is_empty()).map(ToString::to_string))
147
- .unwrap_or_else(|| team_state_key(state))
148
- }