@team-agent/installer 0.4.0 → 0.4.2

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 (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +82 -22
  4. package/crates/team-agent/src/cli/diagnose.rs +6 -2
  5. package/crates/team-agent/src/cli/emit.rs +301 -38
  6. package/crates/team-agent/src/cli/mod.rs +7 -14
  7. package/crates/team-agent/src/cli/status_port.rs +12 -1
  8. package/crates/team-agent/src/cli/tests/base.rs +6 -2
  9. package/crates/team-agent/src/cli/tests/lane_c.rs +1 -1
  10. package/crates/team-agent/src/cli/tests/leader_watch.rs +5 -3
  11. package/crates/team-agent/src/cli/tests/main_preserved.rs +28 -2
  12. package/crates/team-agent/src/cli/tests/peer_allow.rs +19 -0
  13. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +72 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -3
  15. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  16. package/crates/team-agent/src/cli/types.rs +35 -2
  17. package/crates/team-agent/src/coordinator/runtime_detectors.rs +7 -2
  18. package/crates/team-agent/src/coordinator/tick.rs +86 -16
  19. package/crates/team-agent/src/diagnose/comms.rs +10 -2
  20. package/crates/team-agent/src/leader/lease.rs +98 -19
  21. package/crates/team-agent/src/leader/rediscover/tests.rs +21 -7
  22. package/crates/team-agent/src/leader/rediscover.rs +16 -7
  23. package/crates/team-agent/src/leader/start.rs +25 -12
  24. package/crates/team-agent/src/leader/tests/byte_findings.rs +11 -2
  25. package/crates/team-agent/src/leader/tests/lease_claim.rs +248 -7
  26. package/crates/team-agent/src/lifecycle/launch.rs +116 -100
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +5 -12
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +16 -15
  30. package/crates/team-agent/src/lifecycle/types.rs +1 -1
  31. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -8
  32. package/crates/team-agent/src/messaging/send.rs +21 -4
  33. package/crates/team-agent/src/provider/adapter.rs +22 -0
  34. package/crates/team-agent/src/provider/session/capture.rs +228 -0
  35. package/crates/team-agent/src/state/identity.rs +42 -12
  36. package/crates/team-agent/src/state/identity_keys.rs +134 -0
  37. package/crates/team-agent/src/state/mod.rs +8 -0
  38. package/crates/team-agent/src/state/owner_gate.rs +11 -1
  39. package/crates/team-agent/src/state/ownership.rs +556 -0
  40. package/crates/team-agent/src/state/paths.rs +358 -0
  41. package/crates/team-agent/src/state/persist.rs +43 -5
  42. package/crates/team-agent/src/state/projection.rs +13 -5
  43. package/crates/team-agent/src/tmux_backend.rs +12 -0
  44. package/package.json +4 -4
  45. package/skills/team-agent/SKILL.md +3 -3
@@ -1141,12 +1141,21 @@ fn drop_foreign_seeded_owner(
1141
1141
  };
1142
1142
  if owner_pane_belongs_to_other_team(existing, launched_key, pane) {
1143
1143
  let replacement = unbound_launched_owner(launched, launched_key);
1144
- if let Some(obj) = launched.as_object_mut() {
1145
- if let Some(owner) = replacement {
1146
- obj.insert("team_owner".to_string(), owner);
1147
- } else {
1148
- obj.remove("team_owner");
1144
+ // Stage 3a (identity-boundary unified plan, architect direction
1145
+ // 2026-06-23): route owner replace through ownership repository.
1146
+ // Both branches (Some owner replacement; None = removal) preserve
1147
+ // pre-3a behaviour: write_owner overwrites the top-level fields
1148
+ // when given; the None branch falls back to direct mutation (the
1149
+ // repository has no remove API yet — removal is uncommon enough
1150
+ // to leave it inline).
1151
+ if let Some(owner) = replacement {
1152
+ let record = crate::state::ownership::OwnershipWrite::new().with_team_owner(owner);
1153
+ crate::state::ownership::write_owner(launched, launched_key, record);
1154
+ if let Some(obj) = launched.as_object_mut() {
1155
+ obj.remove("owner_epoch");
1149
1156
  }
1157
+ } else if let Some(obj) = launched.as_object_mut() {
1158
+ obj.remove("team_owner");
1150
1159
  obj.remove("owner_epoch");
1151
1160
  }
1152
1161
  }
@@ -1423,11 +1432,13 @@ fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &
1423
1432
  "owner_epoch": owner_epoch,
1424
1433
  "discovery": "quick_start",
1425
1434
  });
1426
- if let Some(obj) = launched.as_object_mut() {
1427
- obj.insert("leader_receiver".to_string(), receiver);
1428
- obj.insert("team_owner".to_string(), owner);
1429
- obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
1430
- }
1435
+ // Stage 3a (identity-boundary unified plan, architect direction 2026-06-23):
1436
+ // route quick-start unbound seed through ownership repository.
1437
+ let record = crate::state::ownership::OwnershipWrite::new()
1438
+ .with_leader_receiver(receiver)
1439
+ .with_team_owner(owner)
1440
+ .with_owner_epoch(owner_epoch);
1441
+ crate::state::ownership::write_owner(launched, launched_key, record);
1431
1442
  }
1432
1443
 
1433
1444
  fn unbound_launched_owner(
@@ -1536,15 +1547,16 @@ mod e22_unbound_owner_provider_tests {
1536
1547
 
1537
1548
  seed_unbound_launched_owner(&mut launched, "team-e22");
1538
1549
 
1550
+ // Stage 3d: canonical owner/receiver at teams.team-e22.
1539
1551
  assert_eq!(
1540
1552
  launched
1541
- .pointer("/team_owner/provider")
1553
+ .pointer("/teams/team-e22/team_owner/provider")
1542
1554
  .and_then(serde_json::Value::as_str),
1543
1555
  Some("copilot")
1544
1556
  );
1545
1557
  assert_eq!(
1546
1558
  launched
1547
- .pointer("/leader_receiver/provider")
1559
+ .pointer("/teams/team-e22/leader_receiver/provider")
1548
1560
  .and_then(serde_json::Value::as_str),
1549
1561
  Some("copilot")
1550
1562
  );
@@ -1561,13 +1573,16 @@ mod e22_unbound_owner_provider_tests {
1561
1573
 
1562
1574
  seed_unbound_launched_owner(&mut launched, "team-e22");
1563
1575
 
1576
+ // Stage 3d: no receiver should be seeded at canonical location.
1564
1577
  assert!(
1565
- launched.get("leader_receiver").is_none(),
1578
+ launched
1579
+ .pointer("/teams/team-e22/leader_receiver")
1580
+ .is_none(),
1566
1581
  "unattributed unbound owner must not seed a codex receiver: {launched}"
1567
1582
  );
1568
1583
  assert!(
1569
1584
  launched
1570
- .pointer("/team_owner/provider")
1585
+ .pointer("/teams/team-e22/team_owner/provider")
1571
1586
  .and_then(serde_json::Value::as_str)
1572
1587
  != Some("codex"),
1573
1588
  "unattributed unbound owner must not silently become codex: {launched}"
@@ -1597,16 +1612,15 @@ mod e22_unbound_owner_provider_tests {
1597
1612
  |pane| (pane.as_str() == "%0").then_some(Provider::Copilot),
1598
1613
  ));
1599
1614
 
1615
+ // Stage 3d: canonical owner/receiver at teams.<team_state_key>.
1616
+ // State has no team_key/team_dir/spec_path → fallback "current".
1617
+ let team_key = crate::state::projection::team_state_key(&state);
1600
1618
  assert_eq!(
1601
- state
1602
- .pointer("/team_owner/provider")
1603
- .and_then(serde_json::Value::as_str),
1619
+ state["teams"][&team_key]["team_owner"]["provider"].as_str(),
1604
1620
  Some("copilot")
1605
1621
  );
1606
1622
  assert_eq!(
1607
- state
1608
- .pointer("/leader_receiver/provider")
1609
- .and_then(serde_json::Value::as_str),
1623
+ state["teams"][&team_key]["leader_receiver"]["provider"].as_str(),
1610
1624
  Some("copilot")
1611
1625
  );
1612
1626
  }
@@ -1623,30 +1637,21 @@ mod e22_unbound_owner_provider_tests {
1623
1637
  caller("", "%0"),
1624
1638
  |_| None,
1625
1639
  ));
1640
+ let team_key = crate::state::projection::team_state_key(&state);
1626
1641
  assert_eq!(
1627
- state
1628
- .pointer("/team_owner/pane_id")
1629
- .and_then(serde_json::Value::as_str),
1642
+ state["teams"][&team_key]["team_owner"]["pane_id"].as_str(),
1630
1643
  Some("%0")
1631
1644
  );
1632
1645
  assert_eq!(
1633
- state
1634
- .pointer("/leader_receiver/pane_id")
1635
- .and_then(serde_json::Value::as_str),
1646
+ state["teams"][&team_key]["leader_receiver"]["pane_id"].as_str(),
1636
1647
  Some("%0")
1637
1648
  );
1638
1649
  assert!(
1639
- state
1640
- .pointer("/leader_receiver/provider")
1641
- .and_then(serde_json::Value::as_str)
1642
- != Some("codex"),
1650
+ state["teams"][&team_key]["leader_receiver"]["provider"].as_str() != Some("codex"),
1643
1651
  "unknown caller pane must not silently seed a codex receiver: {state}"
1644
1652
  );
1645
1653
  assert!(
1646
- state
1647
- .pointer("/team_owner/provider")
1648
- .and_then(serde_json::Value::as_str)
1649
- != Some("codex"),
1654
+ state["teams"][&team_key]["team_owner"]["provider"].as_str() != Some("codex"),
1650
1655
  "unknown caller pane must not silently become codex: {state}"
1651
1656
  );
1652
1657
  }
@@ -2653,18 +2658,17 @@ fn json_team_identity_matches(state: &serde_json::Value, team: &str) -> bool {
2653
2658
  .is_some_and(|value| value == team)
2654
2659
  }
2655
2660
 
2656
- /// `quick_start(agents_dir, name, yes, fresh, team_id)`(`diagnose/quick_start.py:18`)。
2661
+ /// `quick_start(agents_dir, name, yes, team_id)`(`diagnose/quick_start.py:18`)。
2657
2662
  /// 面向用户的零配置入口:编译 team_dir → `launch` → autobind leader receiver → 起
2658
2663
  /// coordinator → `wait_ready` 轮询就绪。归入 lifecycle module(不与 diagnose 混)。
2659
2664
  pub fn quick_start(
2660
2665
  agents_dir: &Path,
2661
2666
  name: Option<&str>,
2662
2667
  yes: bool,
2663
- fresh: bool,
2664
2668
  team_id: Option<&str>,
2665
2669
  ) -> Result<QuickStartReport, LifecycleError> {
2666
2670
  let workspace = team_workspace(agents_dir);
2667
- quick_start_in_workspace(&workspace, agents_dir, name, yes, fresh, team_id)
2671
+ quick_start_in_workspace(&workspace, agents_dir, name, yes, team_id)
2668
2672
  }
2669
2673
 
2670
2674
  pub fn quick_start_in_workspace(
@@ -2672,7 +2676,6 @@ pub fn quick_start_in_workspace(
2672
2676
  agents_dir: &Path,
2673
2677
  name: Option<&str>,
2674
2678
  yes: bool,
2675
- fresh: bool,
2676
2679
  team_id: Option<&str>,
2677
2680
  ) -> Result<QuickStartReport, LifecycleError> {
2678
2681
  let workspace = explicit_quick_start_workspace(workspace);
@@ -2682,7 +2685,6 @@ pub fn quick_start_in_workspace(
2682
2685
  agents_dir,
2683
2686
  name,
2684
2687
  yes,
2685
- fresh,
2686
2688
  team_id,
2687
2689
  &transport,
2688
2690
  )
@@ -2693,7 +2695,6 @@ pub fn quick_start_in_workspace_with_display(
2693
2695
  agents_dir: &Path,
2694
2696
  name: Option<&str>,
2695
2697
  yes: bool,
2696
- fresh: bool,
2697
2698
  team_id: Option<&str>,
2698
2699
  open_display: bool,
2699
2700
  ) -> Result<QuickStartReport, LifecycleError> {
@@ -2704,7 +2705,6 @@ pub fn quick_start_in_workspace_with_display(
2704
2705
  agents_dir,
2705
2706
  name,
2706
2707
  yes,
2707
- fresh,
2708
2708
  team_id,
2709
2709
  &transport,
2710
2710
  open_display,
@@ -2779,13 +2779,12 @@ pub fn quick_start_with_transport(
2779
2779
  agents_dir: &Path,
2780
2780
  name: Option<&str>,
2781
2781
  yes: bool,
2782
- fresh: bool,
2783
2782
  team_id: Option<&str>,
2784
2783
  transport: &dyn Transport,
2785
2784
  ) -> Result<QuickStartReport, LifecycleError> {
2786
2785
  let workspace = team_workspace(agents_dir);
2787
2786
  quick_start_with_transport_in_workspace(
2788
- &workspace, agents_dir, name, yes, fresh, team_id, transport,
2787
+ &workspace, agents_dir, name, yes, team_id, transport,
2789
2788
  )
2790
2789
  }
2791
2790
 
@@ -2794,12 +2793,11 @@ pub fn quick_start_with_transport_in_workspace(
2794
2793
  agents_dir: &Path,
2795
2794
  name: Option<&str>,
2796
2795
  yes: bool,
2797
- fresh: bool,
2798
2796
  team_id: Option<&str>,
2799
2797
  transport: &dyn Transport,
2800
2798
  ) -> Result<QuickStartReport, LifecycleError> {
2801
2799
  quick_start_with_transport_in_workspace_with_display(
2802
- workspace, agents_dir, name, yes, fresh, team_id, transport, true,
2800
+ workspace, agents_dir, name, yes, team_id, transport, true,
2803
2801
  )
2804
2802
  }
2805
2803
 
@@ -2808,7 +2806,6 @@ pub fn quick_start_with_transport_in_workspace_with_display(
2808
2806
  agents_dir: &Path,
2809
2807
  name: Option<&str>,
2810
2808
  yes: bool,
2811
- fresh: bool,
2812
2809
  team_id: Option<&str>,
2813
2810
  transport: &dyn Transport,
2814
2811
  open_display: bool,
@@ -2851,57 +2848,63 @@ pub fn quick_start_with_transport_in_workspace_with_display(
2851
2848
  team_depth.team_depth.saturating_sub(1)
2852
2849
  )));
2853
2850
  }
2854
- if !fresh {
2855
- let state_path = crate::state::persist::runtime_state_path(&workspace);
2856
- if state_path.exists() {
2857
- let state = crate::state::persist::load_runtime_state(&workspace)
2858
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2859
- if requested_team
2860
- .as_deref()
2861
- .is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
2862
- {
2863
- let session_name = state
2864
- .get("session_name")
2865
- .and_then(serde_json::Value::as_str)
2866
- .filter(|s| !s.is_empty())
2867
- .map(SessionName::new);
2868
- let attach_commands = session_name
2869
- .as_ref()
2870
- .map(|session| {
2871
- let windows = quick_start_attach_window_names(&state);
2872
- attach_commands_for_runtime_windows(
2873
- state
2874
- .get("tmux_endpoint")
2875
- .and_then(serde_json::Value::as_str)
2876
- .or_else(|| {
2877
- state.get("tmux_socket").and_then(serde_json::Value::as_str)
2878
- }),
2879
- &workspace,
2880
- session,
2881
- windows.iter().map(String::as_str),
2882
- )
2883
- })
2884
- .unwrap_or_default();
2885
- let mut next_actions = vec![
2886
- "run restart to resume the existing team or pass --fresh to replace it"
2887
- .to_string(),
2888
- ];
2889
- if session_name.is_some() {
2890
- if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
2891
- next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
2892
- &workspace,
2893
- ));
2894
- }
2895
- next_actions.extend(attach_commands.iter().cloned());
2851
+ let state_path = crate::state::persist::runtime_state_path(&workspace);
2852
+ if state_path.exists() {
2853
+ let state = crate::state::persist::load_runtime_state(&workspace)
2854
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
2855
+ if requested_team
2856
+ .as_deref()
2857
+ .is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
2858
+ {
2859
+ let session_name = state
2860
+ .get("session_name")
2861
+ .and_then(serde_json::Value::as_str)
2862
+ .filter(|s| !s.is_empty())
2863
+ .map(SessionName::new);
2864
+ let attach_commands = session_name
2865
+ .as_ref()
2866
+ .map(|session| {
2867
+ let windows = quick_start_attach_window_names(&state);
2868
+ attach_commands_for_runtime_windows(
2869
+ state
2870
+ .get("tmux_endpoint")
2871
+ .and_then(serde_json::Value::as_str)
2872
+ .or_else(|| {
2873
+ state.get("tmux_socket").and_then(serde_json::Value::as_str)
2874
+ }),
2875
+ &workspace,
2876
+ session,
2877
+ windows.iter().map(String::as_str),
2878
+ )
2879
+ })
2880
+ .unwrap_or_default();
2881
+ // Stage QR (design doc .team/artifacts/quickstart-restart-separation-design.md):
2882
+ // quick-start is initial-creation-only. When the team
2883
+ // already has runtime state, do NOT mention `--fresh`
2884
+ // (it's been removed); steer the operator to the
2885
+ // restart flow which owns resume + reset semantics.
2886
+ let mut next_actions = vec![
2887
+ "this team already has runtime state — use `team-agent restart` \
2888
+ to resume it (quick-start is for first-time creation only). \
2889
+ If recovery is impossible and the operator EXPLICITLY \
2890
+ accepts losing context, restart accepts `--allow-fresh`."
2891
+ .to_string(),
2892
+ ];
2893
+ if session_name.is_some() {
2894
+ if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
2895
+ next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
2896
+ &workspace,
2897
+ ));
2896
2898
  }
2897
- return Ok(QuickStartReport::ExistingRuntime {
2898
- team: requested_team.clone(),
2899
- session_name,
2900
- state_path: Some(state_path),
2901
- next_actions,
2902
- attach_commands,
2903
- });
2899
+ next_actions.extend(attach_commands.iter().cloned());
2904
2900
  }
2901
+ return Ok(QuickStartReport::ExistingRuntime {
2902
+ team: requested_team.clone(),
2903
+ session_name,
2904
+ state_path: Some(state_path),
2905
+ next_actions,
2906
+ attach_commands,
2907
+ });
2905
2908
  }
2906
2909
  }
2907
2910
  // CR-040/042: repeated quick-start from one template with distinct --team-id/--name
@@ -2982,6 +2985,16 @@ pub fn quick_start_with_transport_in_workspace_with_display(
2982
2985
  let mut next_actions = vec![format!(
2983
2986
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
2984
2987
  )];
2988
+ // Stage QR (design doc
2989
+ // .team/artifacts/quickstart-restart-separation-design.md):
2990
+ // quick-start is initial-creation-only. After success, remind the
2991
+ // operator that subsequent starts must go through restart so the
2992
+ // user's context (sessions, ownership, tasks) survives.
2993
+ next_actions.push(
2994
+ "quick-start initialized this team; for all subsequent starts use \
2995
+ `team-agent restart` to resume context (quick-start refuses on \
2996
+ already-initialised teams)".to_string(),
2997
+ );
2985
2998
  if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
2986
2999
  next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(&workspace));
2987
3000
  }
@@ -4681,11 +4694,14 @@ fn seed_launched_owner_from_caller_with_provider_lookup(
4681
4694
  ) {
4682
4695
  receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
4683
4696
  }
4684
- if let Some(obj) = state.as_object_mut() {
4685
- obj.insert("leader_receiver".to_string(), receiver);
4686
- obj.insert("team_owner".to_string(), owner);
4687
- obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
4688
- }
4697
+ // Stage 3a (identity-boundary unified plan, architect direction 2026-06-23):
4698
+ // route quick-start attached-from-env seed through ownership repository.
4699
+ let team_key = crate::state::projection::team_state_key(state);
4700
+ let record = crate::state::ownership::OwnershipWrite::new()
4701
+ .with_leader_receiver(receiver)
4702
+ .with_team_owner(owner)
4703
+ .with_owner_epoch(owner_epoch);
4704
+ crate::state::ownership::write_owner(state, &team_key, record);
4689
4705
  true
4690
4706
  }
4691
4707
 
@@ -606,18 +606,11 @@ pub fn restart_with_transport_with_session_convergence_deadline(
606
606
  restart_readiness_deadline(readiness_deadline_ms),
607
607
  restart_readiness_poll_interval(),
608
608
  )?;
609
- let attach_windows = crate::lifecycle::launch::attach_window_names_for_state_agents(
610
- &state,
611
- successful_agents
612
- .iter()
613
- .map(|decision| decision.agent_id.as_str()),
614
- );
615
- let attach_commands = crate::tmux_backend::attach_commands_for_windows(
616
- &selected.run_workspace,
617
- &session_name,
618
- attach_windows.iter().map(String::as_str),
619
- );
620
- let mut next_actions = attach_commands.clone();
609
+ let attach_commands =
610
+ crate::tmux_backend::attach_command_for_session(&selected.run_workspace, &session_name)
611
+ .into_iter()
612
+ .collect::<Vec<_>>();
613
+ let mut next_actions = Vec::new();
621
614
  if !failed_agents.is_empty() {
622
615
  next_actions.extend(restart_failure_next_actions(&failed_agents));
623
616
  write_restart_completed_event(
@@ -890,7 +890,7 @@ fn save_snapshot_writes_atomic_state_json_under_teams_dir() {
890
890
  fn quick_start_empty_dir_returns_typed_report_not_error_path_only() {
891
891
  let ws = temp_ws();
892
892
  // 空目录无可编译 team:应是 typed 阻塞 / 编译错,绝不 Ready。
893
- match quick_start(&ws, None, false, false, None) {
893
+ match quick_start(&ws, None, false, None) {
894
894
  Ok(QuickStartReport::Ready { .. }) => {
895
895
  panic!("空 agents_dir 不应 Ready —— 无 team 可编译")
896
896
  }
@@ -115,7 +115,7 @@ fn unowned_running_ws() -> PathBuf {
115
115
  fn quick_start_compiles_real_spec_to_team_spec_yaml() {
116
116
  let team = quick_start_team_dir(QS_VALID_ROLE);
117
117
  let transport = OfflineTransport::new();
118
- let result = quick_start_with_transport(&team, None, true, true, None, &transport);
118
+ let result = quick_start_with_transport(&team, None, true, None, &transport);
119
119
 
120
120
  // OBSERVABLE 1 (real compiler ran; spawn-independent): the compiled spec is written.
121
121
  // E5: spec lands in .team/runtime/<team_key>/ (NOT the user team dir).
@@ -153,7 +153,7 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
153
153
  std::fs::write(team.join("agents").join("implementer.md"), QS_VALID_ROLE).unwrap();
154
154
 
155
155
  let transport = OfflineTransport::new();
156
- let result = quick_start_with_transport(&team, None, true, true, None, &transport);
156
+ let result = quick_start_with_transport(&team, None, true, None, &transport);
157
157
  assert!(
158
158
  matches!(result, Ok(QuickStartReport::Ready { .. })),
159
159
  "quick-start failed: {result:?}"
@@ -208,6 +208,7 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
208
208
  detail: false,
209
209
  summary: false,
210
210
  json: true,
211
+ team: None,
211
212
  });
212
213
  assert!(
213
214
  status.is_ok(),
@@ -218,6 +219,7 @@ fn quick_start_teamdir_under_dot_team_uses_project_workspace_for_status_and_coll
218
219
  result_file: None,
219
220
  workspace: team.clone(),
220
221
  json: true,
222
+ team: None,
221
223
  });
222
224
  assert!(
223
225
  collect.is_ok(),
@@ -239,7 +241,6 @@ fn quick_start_default_workspace_compiled_spec_uses_project_root() {
239
241
  &workspace,
240
242
  None,
241
243
  true,
242
- true,
243
244
  None,
244
245
  &transport,
245
246
  )
@@ -286,7 +287,7 @@ fn quick_start_default_workspace_compiled_spec_uses_project_root() {
286
287
  fn quick_start_invalid_role_doc_surfaces_real_compile_error() {
287
288
  let team = quick_start_team_dir(QS_ROLE_NO_PROVIDER);
288
289
  let transport = OfflineTransport::new();
289
- let result = quick_start_with_transport(&team, None, true, true, None, &transport);
290
+ let result = quick_start_with_transport(&team, None, true, None, &transport);
290
291
 
291
292
  let text = format!("{result:?}");
292
293
  assert!(
@@ -413,7 +414,7 @@ fn fork_agent_proceeds_past_owner_gate_for_unowned_workspace() {
413
414
  happens inside launch's real spawn). Acceptance-crate boundary, not a unit RED."]
414
415
  fn quick_start_full_ready_real_spawn() {
415
416
  let team = quick_start_team_dir(QS_VALID_ROLE);
416
- let report = quick_start(&team, None, true, true, None).expect("quick_start launches the team");
417
+ let report = quick_start(&team, None, true, None).expect("quick_start launches the team");
417
418
  assert!(
418
419
  matches!(report, QuickStartReport::Ready { .. }),
419
420
  "got {report:?}"
@@ -532,7 +533,7 @@ fn unowned_running_ws_all_paused() -> PathBuf {
532
533
  fn spine_quick_start_seeds_state_under_team_workspace_not_team_dir() {
533
534
  let team = quick_start_team_dir(QS_VALID_ROLE); // <base>/teamdir
534
535
  let transport = OfflineTransport::new();
535
- let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
536
+ let _ = quick_start_with_transport(&team, None, true, None, &transport);
536
537
  let workspace = team.parent().unwrap(); // team_workspace(<base>/teamdir) = <base>
537
538
  let ws_state = crate::model::paths::runtime_dir(workspace).join("state.json");
538
539
  assert!(
@@ -1060,7 +1061,7 @@ fn quick_start_with_transport_spawns_workers_not_dry_run() {
1060
1061
  seed_healthy_coordinator(workspace);
1061
1062
  let transport = OfflineTransport::new();
1062
1063
 
1063
- let report = quick_start_with_transport(&team, None, true, true, None, &transport)
1064
+ let report = quick_start_with_transport(&team, None, true, None, &transport)
1064
1065
  .expect("quick_start_with_transport must reach a report");
1065
1066
 
1066
1067
  let launch = match report {
@@ -1153,7 +1154,7 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1153
1154
  seed_healthy_coordinator(workspace);
1154
1155
  let transport = OfflineTransport::new();
1155
1156
 
1156
- let report = quick_start_with_transport(&team, None, true, true, None, &transport)
1157
+ let report = quick_start_with_transport(&team, None, true, None, &transport)
1157
1158
  .expect("quick_start_with_transport must reach Ready");
1158
1159
 
1159
1160
  let (launch, attach_commands, display_backend) = match report {
@@ -1222,7 +1223,7 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
1222
1223
  let endpoint = "/tmp/team-agent-layout-selected-socket";
1223
1224
  let transport = OfflineTransport::new().with_tmux_endpoint(endpoint);
1224
1225
 
1225
- let report = quick_start_with_transport(&team, None, true, true, None, &transport)
1226
+ let report = quick_start_with_transport(&team, None, true, None, &transport)
1226
1227
  .expect("quick_start_with_transport must reach Ready");
1227
1228
  let attach_commands = match report {
1228
1229
  QuickStartReport::Ready { attach_commands, .. } => attach_commands,
@@ -1297,7 +1298,7 @@ fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command
1297
1298
  .expect("seed managed leader state");
1298
1299
  let transport = OfflineTransport::new();
1299
1300
 
1300
- let report = quick_start_with_transport(&team, None, true, true, None, &transport)
1301
+ let report = quick_start_with_transport(&team, None, true, None, &transport)
1301
1302
  .expect("quick_start_with_transport must reach Ready");
1302
1303
  let attach_commands = match report {
1303
1304
  QuickStartReport::Ready { attach_commands, .. } => attach_commands,
@@ -1375,7 +1376,7 @@ fn quick_start_no_display_keeps_one_window_per_agent() {
1375
1376
  let transport = OfflineTransport::new();
1376
1377
 
1377
1378
  let report = quick_start_with_transport_in_workspace_with_display(
1378
- workspace, &team, None, true, true, None, &transport, false,
1379
+ workspace, &team, None, true, None, &transport, false,
1379
1380
  )
1380
1381
  .expect("quick_start_with_transport must reach Ready");
1381
1382
 
@@ -1407,7 +1408,7 @@ fn quick_start_no_display_keeps_one_window_per_agent() {
1407
1408
  #[ignore = "real-machine: live tmux session + resident coordinator daemon (framework verifies via ps)"]
1408
1409
  fn quick_start_fresh_ws_spawns_resident_tmux_and_coordinator() {
1409
1410
  let team = quick_start_team_dir(QS_VALID_ROLE);
1410
- let report = quick_start(&team, None, true, true, None).expect("quick_start");
1411
+ let report = quick_start(&team, None, true, None).expect("quick_start");
1411
1412
  match report {
1412
1413
  QuickStartReport::Ready { launch, .. } => {
1413
1414
  assert!(
@@ -2462,7 +2463,7 @@ fn start_agent_adaptive_restarts_missing_pane_in_existing_layout_window() {
2462
2463
  fn quick_start_seeds_tasks_key_from_compiled_spec() {
2463
2464
  let team = quick_start_team_dir(QS_VALID_ROLE);
2464
2465
  let transport = OfflineTransport::new();
2465
- let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
2466
+ let _ = quick_start_with_transport(&team, None, true, None, &transport);
2466
2467
  let workspace = team
2467
2468
  .parent()
2468
2469
  .expect("team_workspace(<base>/teamdir) = <base>");
@@ -2519,7 +2520,7 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
2519
2520
  let _tmux = EnvVarGuard::unset("TMUX");
2520
2521
  let team = quick_start_team_dir(QS_VALID_ROLE);
2521
2522
  let transport = OfflineTransport::new();
2522
- let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
2523
+ let _ = quick_start_with_transport(&team, None, true, None, &transport);
2523
2524
  let workspace = team
2524
2525
  .parent()
2525
2526
  .expect("team_workspace(<base>/teamdir) = <base>");
@@ -2608,7 +2609,7 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
2608
2609
  let _clock_guard = EnvVarGuard::set("TEAM_AGENT_TEST_FIXED_SPAWNED_AT", FIXED_SPAWNED_AT);
2609
2610
  let team = quick_start_team_dir(QS_VALID_ROLE);
2610
2611
  let transport = OfflineTransport::new();
2611
- let _ = quick_start_with_transport(&team, None, true, true, None, &transport);
2612
+ let _ = quick_start_with_transport(&team, None, true, None, &transport);
2612
2613
  let workspace = team
2613
2614
  .parent()
2614
2615
  .expect("team_workspace(<base>/teamdir) = <base>");
@@ -498,7 +498,7 @@ pub enum QuickStartReport {
498
498
  /// failed to spawn) so the CLI surface cannot lie about availability.
499
499
  worker_readiness: QuickStartReadiness,
500
500
  },
501
- /// 已有 runtime state,非 --fresh 引导用 restart(`quick_start.py:42`)
501
+ /// 已有 runtime state → quick-start 拒绝并引导用 restart。
502
502
  ExistingRuntime {
503
503
  team: Option<String>,
504
504
  session_name: Option<SessionName>,
@@ -230,16 +230,23 @@ pub fn claim_leader_receiver(
230
230
  "runtime state root is not an object".to_string(),
231
231
  ));
232
232
  };
233
- let owner = root
234
- .entry("team_owner")
235
- .or_insert_with(|| serde_json::json!({}));
236
- if let Some(owner) = owner.as_object_mut() {
233
+ // Build the updated owner + receiver values in-place (field-level
234
+ // mutation preserves any extra fields the caller carries), then publish
235
+ // through the ownership repository. This keeps `claim_leader_receiver`'s
236
+ // legacy field-merge semantics while sourcing all owner mutations from
237
+ // one API surface (Stage 3a, architect direction 2026-06-23).
238
+ let mut owner_value = root
239
+ .get("team_owner")
240
+ .cloned()
241
+ .unwrap_or_else(|| serde_json::json!({}));
242
+ if let Some(owner) = owner_value.as_object_mut() {
237
243
  owner.insert("owner_epoch".to_string(), serde_json::json!(next_epoch));
238
244
  }
239
- let receiver = root
240
- .entry("leader_receiver")
241
- .or_insert_with(|| serde_json::json!({}));
242
- if let Some(receiver) = receiver.as_object_mut() {
245
+ let mut receiver_value = root
246
+ .get("leader_receiver")
247
+ .cloned()
248
+ .unwrap_or_else(|| serde_json::json!({}));
249
+ if let Some(receiver) = receiver_value.as_object_mut() {
243
250
  receiver.insert("mode".to_string(), serde_json::json!("direct_tmux"));
244
251
  receiver.insert("owner_epoch".to_string(), serde_json::json!(next_epoch));
245
252
  copy_candidate_field(receiver, candidate, "pane_id");
@@ -255,6 +262,13 @@ pub fn claim_leader_receiver(
255
262
  receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
256
263
  }
257
264
  }
265
+ // The `root` borrow above is released by NLL before this call.
266
+ let team_key = crate::state::projection::team_state_key(state);
267
+ let record = crate::state::ownership::OwnershipWrite::new()
268
+ .with_team_owner(owner_value)
269
+ .with_leader_receiver(receiver_value)
270
+ .with_owner_epoch(next_epoch);
271
+ crate::state::ownership::write_owner(state, &team_key, record);
258
272
  crate::state::persist::save_runtime_state(workspace, state)?;
259
273
  event_log.write(
260
274
  "leader_receiver.claimed",