@team-agent/installer 0.5.50 → 0.5.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. package/package.json +4 -4
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ use super::{reapply_scope, ReapplyScope, StateWriteIntent};
2
+
3
+ #[test]
4
+ fn mcp_reapply_scope_matches_legacy_helpers() {
5
+ assert!(
6
+ reapply_scope(&StateWriteIntent::McpUpdateStateNote {
7
+ team_key: Some("team-a"),
8
+ }) == ReapplyScope::Team
9
+ );
10
+ assert!(
11
+ reapply_scope(&StateWriteIntent::McpAssignTask {
12
+ team_key: Some("team-a"),
13
+ task_id: "task-a",
14
+ }) == ReapplyScope::Root
15
+ );
16
+ }
17
+
18
+ #[test]
19
+ fn optional_unmigrated_read_preserves_missing_corrupt_and_valid_shapes(
20
+ ) -> Result<(), Box<dyn std::error::Error>> {
21
+ let nonce = std::time::SystemTime::now()
22
+ .duration_since(std::time::UNIX_EPOCH)?
23
+ .as_nanos();
24
+ let workspace = std::env::temp_dir().join(format!("state-repository-read-{nonce}"));
25
+ let repository = super::StateRepository::new(&workspace);
26
+ assert!(repository
27
+ .load_workspace_if_exists_without_migrations()?
28
+ .is_none());
29
+
30
+ let path = super::helper_workspace_path(&workspace);
31
+ if let Some(parent) = path.parent() {
32
+ std::fs::create_dir_all(parent)?;
33
+ }
34
+ std::fs::write(&path, b"{not-json")?;
35
+ assert!(matches!(
36
+ repository.load_workspace_if_exists_without_migrations(),
37
+ Err(super::StateError::Json(_))
38
+ ));
39
+
40
+ std::fs::write(&path, br#"{"legacy":true}"#)?;
41
+ assert_eq!(
42
+ repository.load_workspace_if_exists_without_migrations()?,
43
+ Some(serde_json::json!({"legacy": true}))
44
+ );
45
+ std::fs::remove_dir_all(workspace)?;
46
+ Ok(())
47
+ }