@team-agent/installer 0.5.77 → 0.5.80

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 (95) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +161 -29
  4. package/crates/team-agent/src/cli/diagnose.rs +333 -68
  5. package/crates/team-agent/src/cli/emit.rs +24 -8
  6. package/crates/team-agent/src/cli/mod.rs +543 -98
  7. package/crates/team-agent/src/cli/models.rs +393 -32
  8. package/crates/team-agent/src/cli/named_address.rs +19 -5
  9. package/crates/team-agent/src/cli/profile.rs +35 -4
  10. package/crates/team-agent/src/cli/spec.rs +7 -2
  11. package/crates/team-agent/src/cli/status_port/compact.rs +12 -8
  12. package/crates/team-agent/src/cli/status_port/runtime.rs +3 -1
  13. package/crates/team-agent/src/cli/status_port/snapshot.rs +15 -12
  14. package/crates/team-agent/src/cli/status_port/store.rs +1 -1
  15. package/crates/team-agent/src/cli/status_port/tests.rs +37 -1
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +62 -8
  17. package/crates/team-agent/src/cli/tests/named_address.rs +70 -0
  18. package/crates/team-agent/src/cli/tests/verb_profile.rs +81 -6
  19. package/crates/team-agent/src/cli/types.rs +1 -0
  20. package/crates/team-agent/src/coordinator/steps/abnormal.rs +122 -2
  21. package/crates/team-agent/src/leader/lease.rs +492 -33
  22. package/crates/team-agent/src/leader/owner_bind.rs +40 -12
  23. package/crates/team-agent/src/leader/rediscover/tests.rs +65 -0
  24. package/crates/team-agent/src/leader/rediscover.rs +36 -6
  25. package/crates/team-agent/src/leader/start.rs +197 -88
  26. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +15 -9
  27. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +28 -227
  28. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +0 -18
  29. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +94 -227
  30. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +59 -10
  31. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +86 -23
  32. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1449 -282
  33. package/crates/team-agent/src/lifecycle/launch/readiness.rs +349 -4
  34. package/crates/team-agent/src/lifecycle/launch/spawn.rs +11 -11
  35. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +35 -3
  36. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +16 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +30 -7
  38. package/crates/team-agent/src/lifecycle/profile_launch.rs +37 -3
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +112 -100
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +82 -52
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +996 -66
  42. package/crates/team-agent/src/lifecycle/restart.rs +2 -2
  43. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +3 -0
  44. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +15 -0
  45. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +5 -0
  46. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +11 -13
  47. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +119 -4
  48. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +12 -0
  49. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +22 -0
  50. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +12 -0
  51. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +265 -198
  52. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +14 -30
  53. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +3 -0
  54. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_spaced_ids.stdout.txt +9 -0
  55. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +2 -5
  56. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +10 -0
  57. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +58 -35
  58. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +10 -0
  59. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +9 -0
  60. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -2
  61. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -168
  62. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +145 -212
  63. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +16 -2
  64. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +54 -9
  65. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +71 -0
  66. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +78 -0
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +3 -0
  68. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +51 -10
  69. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +50 -7
  70. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +137 -0
  71. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  72. package/crates/team-agent/src/mcp_server/wire.rs +140 -11
  73. package/crates/team-agent/src/messaging/delivery.rs +221 -80
  74. package/crates/team-agent/src/messaging/leader_channel.rs +56 -0
  75. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +127 -0
  76. package/crates/team-agent/src/messaging/tests/runtime.rs +10 -2
  77. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +2 -5
  78. package/crates/team-agent/src/provider/session_scan/common/tests.rs +204 -0
  79. package/crates/team-agent/src/provider/session_scan/common.rs +24 -2
  80. package/crates/team-agent/src/state/abnormal_watch/tests/mod.rs +248 -0
  81. package/crates/team-agent/src/state/abnormal_watch.rs +144 -0
  82. package/crates/team-agent/src/state/mod.rs +1 -0
  83. package/crates/team-agent/src/state/persist.rs +102 -4
  84. package/crates/team-agent/src/state/projection.rs +7 -1
  85. package/crates/team-agent/src/state/repository.rs +29 -0
  86. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +43 -21
  87. package/crates/team-agent/src/tmux_backend/tests.rs +149 -4
  88. package/crates/team-agent/src/tmux_backend.rs +239 -127
  89. package/crates/team-agent/src/transport.rs +78 -0
  90. package/crates/team-agent/src/transport_factory.rs +53 -0
  91. package/package.json +4 -5
  92. package/skills/team-agent/SKILL.md +45 -17
  93. package/skills/team-agent/command-coverage.json +4 -29
  94. package/npm/check-skill-links.mjs +0 -88
  95. package/skills/team-agent/references/team-agent-operator.md +0 -672
@@ -142,7 +142,28 @@ pub fn bind_owner_from_caller_pane(
142
142
  .as_ref()
143
143
  .and_then(|info| info.current_command.clone())
144
144
  .unwrap_or_else(|| tmux_pane_current_command(workspace, &pane).unwrap_or_default());
145
- let provider = bind_provider_from_env_or_pane(&caller_current_command, caller_info);
145
+ let Some(provider) = bind_provider_from_env_or_pane(&caller_current_command, caller_info) else {
146
+ let hint = "set TEAM_AGENT_LEADER_PROVIDER or run from a provider pane; unknown commands are not Codex";
147
+ event_log.write(
148
+ LeaderEvent::OwnerBindRefused.name(),
149
+ json!({
150
+ "reason": serde_json::to_value(LeaseReason::CallerNotLeaderShaped)?,
151
+ "caller_pane_id": pane,
152
+ "caller_current_command": caller_current_command.clone(),
153
+ "team_id": team_id.as_str(),
154
+ "hint": hint,
155
+ }),
156
+ )?;
157
+ return Ok(OwnerBindResult {
158
+ ok: false,
159
+ owner: None,
160
+ caller_pane_id: PaneId::new(pane),
161
+ caller_current_command,
162
+ team_id: team_id.clone(),
163
+ reason: Some(LeaseReason::CallerNotLeaderShaped),
164
+ hint: Some(hint.to_string()),
165
+ });
166
+ };
146
167
  let machine_fingerprint = std::env::var("TEAM_AGENT_MACHINE_FINGERPRINT").unwrap_or_default();
147
168
  let os_user = std::env::var("USER")
148
169
  .or_else(|_| std::env::var("USERNAME"))
@@ -207,17 +228,13 @@ fn emit_owner_bound_event(
207
228
  Ok(())
208
229
  }
209
230
 
210
- fn bind_provider_from_env_or_pane(command: &str, pane: Option<PaneInfo>) -> Provider {
231
+ fn bind_provider_from_env_or_pane(command: &str, pane: Option<PaneInfo>) -> Option<Provider> {
211
232
  let env_provider = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
212
233
  .ok()
213
234
  .and_then(|raw| super::helpers::parse_provider(&raw));
214
235
  env_provider
215
236
  .or_else(|| pane.as_ref().and_then(super::attribute_pane_provider))
216
237
  .or_else(|| super::attribute_command_provider(command))
217
- // E11 层2:未知命令不再静默默认 codex(会误绑任意 provider + 喂错分类器)。
218
- // 无法识别时回落 Codex 仅作最末兜底,且该路径已被统一归因入口的显式 None 收窄
219
- // (调用方理应只在已知 leader 命令上 bind);保留以不改 fn 签名/上游 panic 面。
220
- .unwrap_or(Provider::Codex)
221
238
  }
222
239
 
223
240
  pub(crate) fn strict_owner_bind_provider(
@@ -352,13 +369,8 @@ mod e11_provider_bind_tests {
352
369
  }
353
370
 
354
371
  #[test]
355
- fn strict_provider_never_falls_back_from_unknown_explicit_or_command() {
356
- assert_eq!(
357
- strict_owner_bind_provider(Some("pi"), "codex"),
358
- Some(Provider::Pi)
359
- );
372
+ fn provider_independent_rejects_unknown_or_node_without_fallback() {
360
373
  assert_eq!(strict_owner_bind_provider(Some("unknown"), "codex"), None);
361
- assert_eq!(strict_owner_bind_provider(None, "pi"), Some(Provider::Pi));
362
374
  assert_eq!(strict_owner_bind_provider(None, "node"), None);
363
375
  }
364
376
 
@@ -394,3 +406,19 @@ mod e11_provider_bind_tests {
394
406
  assert_eq!(owner_bind_provider_wire("totally-unknown"), "");
395
407
  }
396
408
  }
409
+
410
+ #[cfg(test)]
411
+ mod claim_pi_carrier_tests {
412
+ use super::*;
413
+
414
+ #[test]
415
+ fn strict_provider_never_falls_back_from_unknown_explicit_or_command() {
416
+ assert_eq!(
417
+ strict_owner_bind_provider(Some("pi"), "codex"),
418
+ Some(Provider::Pi)
419
+ );
420
+ assert_eq!(strict_owner_bind_provider(Some("unknown"), "codex"), None);
421
+ assert_eq!(strict_owner_bind_provider(None, "pi"), Some(Provider::Pi));
422
+ assert_eq!(strict_owner_bind_provider(None, "node"), None);
423
+ }
424
+ }
@@ -204,6 +204,71 @@ fn try_readopt_refuses_when_different_live_owner_uuid_mismatches() {
204
204
  assert!(state.get("team_owner").is_none());
205
205
  }
206
206
 
207
+ #[test]
208
+ fn try_readopt_inactive_bash_wrapper_with_matching_owner_uuid() {
209
+ let ws = ws("readopt-bash-ok");
210
+ let mut state = json!({"session_name": "sess"});
211
+ let uuid = "c1e3f7a7aeaed95491667b4d9c3ce6fb";
212
+ let mut recv = receiver("%0", uuid, 1);
213
+ let owner = owner("%0", uuid, 1);
214
+ let event_log = crate::event_log::EventLog::new(&ws);
215
+ let pane = json!({
216
+ "pane_id": "%0",
217
+ "session_name": "sess",
218
+ "window_name": "pi",
219
+ "pane_current_command": "bash",
220
+ "pane_current_path": ws.join("sub").to_string_lossy(),
221
+ "active": false,
222
+ "leader_session_uuid": uuid,
223
+ "leader_env": {
224
+ "TEAM_AGENT_LEADER_SESSION_UUID": uuid,
225
+ "TEAM_AGENT_WORKSPACE": ws.to_string_lossy(),
226
+ "TEAM_AGENT_TEAM_ID": "sess"
227
+ }
228
+ });
229
+ let got = try_readopt_leader_pane(
230
+ &ws,
231
+ &mut state,
232
+ &mut recv,
233
+ &pane,
234
+ &json!({"targets": [pane.clone()]}),
235
+ Some(&owner),
236
+ Provider::Codex,
237
+ LeaseSource::Manual,
238
+ &event_log,
239
+ )
240
+ .unwrap();
241
+ assert!(got.is_some(), "matching inactive wrapper must readopt");
242
+ }
243
+
244
+ #[test]
245
+ fn try_readopt_bash_without_recorded_owner_uuid_refuses() {
246
+ let ws = ws("readopt-bash-no-id");
247
+ let mut state = json!({});
248
+ let mut recv = receiver("%0", "c1e3f7a7aeaed95491667b4d9c3ce6fb", 1);
249
+ let event_log = crate::event_log::EventLog::new(&ws);
250
+ let pane = json!({
251
+ "pane_id": "%0",
252
+ "session_name": "sess",
253
+ "pane_current_command": "bash",
254
+ "pane_current_path": ws.join("sub").to_string_lossy(),
255
+ "active": false
256
+ });
257
+ let got = try_readopt_leader_pane(
258
+ &ws,
259
+ &mut state,
260
+ &mut recv,
261
+ &pane,
262
+ &json!({"targets": [pane.clone()]}),
263
+ None,
264
+ Provider::Codex,
265
+ LeaseSource::Manual,
266
+ &event_log,
267
+ )
268
+ .unwrap();
269
+ assert!(got.is_none());
270
+ }
271
+
207
272
  #[test]
208
273
  fn rediscover_updates_unique_env_triple_match() {
209
274
  let ws = ws("rediscover");
@@ -42,7 +42,12 @@ pub fn try_readopt_leader_pane(
42
42
  )?;
43
43
  return Ok(None);
44
44
  };
45
- if !readopt_candidate_is_usable_leader(workspace, &candidate) {
45
+ if !readopt_candidate_is_usable_leader(
46
+ workspace,
47
+ &candidate,
48
+ owner_record,
49
+ receiver_provider,
50
+ ) {
46
51
  event_log.write(
47
52
  LeaderEvent::ReceiverRebindRequired.name(),
48
53
  json!({"reason": "leader_pane_unusable"}),
@@ -598,14 +603,39 @@ fn target_iter(targets: &Value) -> Vec<LeaderTarget> {
598
603
  Vec::new()
599
604
  }
600
605
 
601
- fn readopt_candidate_is_usable_leader(workspace: &Path, target: &LeaderTarget) -> bool {
602
- if !target.active || target.provider.is_none() {
603
- return false;
604
- }
605
- target
606
+ fn readopt_candidate_is_usable_leader(
607
+ workspace: &Path,
608
+ target: &LeaderTarget,
609
+ owner_record: Option<&TeamOwner>,
610
+ receiver_provider: Provider,
611
+ ) -> bool {
612
+ if !target
606
613
  .current_path
607
614
  .as_deref()
608
615
  .is_some_and(|path| path_in_workspace(path, workspace))
616
+ {
617
+ return false;
618
+ }
619
+ if target.provider.is_some() {
620
+ return true;
621
+ }
622
+ let Some(owner) = owner_record else {
623
+ return false;
624
+ };
625
+ if owner.pane_id.as_str() != target.pane_id.as_str() {
626
+ return false;
627
+ }
628
+ if owner.provider != receiver_provider {
629
+ return false;
630
+ }
631
+ match (
632
+ owner.leader_session_uuid.as_ref(),
633
+ target.leader_session_uuid.as_ref(),
634
+ ) {
635
+ (Some(recorded), Some(live)) => recorded == live,
636
+ (Some(_), None) => true,
637
+ (None, _) => false,
638
+ }
609
639
  }
610
640
 
611
641
  fn rediscover_candidate_is_usable_leader(target: &LeaderTarget) -> bool {
@@ -1412,13 +1412,6 @@ extern "C" fn managed_term_handler(_: libc::c_int) {
1412
1412
  }
1413
1413
 
1414
1414
  fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
1415
- let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
1416
- let target = Target::Pane(watch.spawned.pane_id.clone());
1417
- if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
1418
- if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
1419
- return true;
1420
- }
1421
- }
1422
1415
  matches!(
1423
1416
  leader_provider_health(
1424
1417
  watch.transport,
@@ -1674,65 +1667,21 @@ fn ensure_managed_provider_live_after_attach(
1674
1667
  )))
1675
1668
  }
1676
1669
 
1677
- /// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
1678
- /// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
1679
- /// returns `Live` even when the provider has exited and the wrapper shell
1680
- /// remains at its inert tail. This function distinguishes
1681
- /// `provider_alive` from `provider_exited` by:
1682
- /// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
1683
- /// 2. If the current command matches the expected provider binary (or
1684
- /// one of its known aliases), report `Alive`.
1685
- /// 3. If the current command is a shell tail process AND the pane
1686
- /// content contains the exit marker `[team-agent] <provider> exited`
1687
- /// (emitted by `leader_shell_wrapper_command`), report
1688
- /// `ProviderExited`.
1689
- /// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
1690
- /// conservative default — avoid false-positive exit alarms.
1691
- ///
1692
- /// Note: when the leader shell wrapper is used (CR C-2), a provider exit
1693
- /// leaves the pane as an inert `/bin/sh -c` tail with the exit marker in
1694
- /// scrollback. Pre-wrapper code that hit `exec claude` would have left the
1695
- /// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
1696
- /// failure mode requires this richer health check to surface
1697
- /// `leader_provider_exited` as a distinct status.
1670
+ /// Reconcile real pane liveness with the supervising wrapper's exit receipt.
1671
+ /// Restored provider output may contain any exit-marker text; it is not process
1672
+ /// state and must never authorize destructive managed-window cleanup.
1698
1673
  pub fn leader_provider_health(
1699
1674
  transport: &dyn Transport,
1700
1675
  pane_id: &PaneId,
1701
- expected_provider_label: &str,
1676
+ _expected_provider_label: &str,
1702
1677
  ) -> LeaderProviderHealth {
1703
- use crate::transport::{CaptureRange, PaneField, Target};
1704
- let liveness = transport.liveness(pane_id).ok();
1705
- if matches!(liveness, Some(PaneLiveness::Dead)) {
1678
+ if matches!(transport.liveness(pane_id), Ok(PaneLiveness::Dead)) {
1706
1679
  return LeaderProviderHealth::Unreachable;
1707
1680
  }
1708
- let target = Target::Pane(pane_id.clone());
1709
- let current_command = transport
1710
- .query(&target, PaneField::PaneCurrentCommand)
1711
- .ok()
1712
- .flatten()
1713
- .map(|s| s.trim().to_lowercase())
1714
- .unwrap_or_default();
1715
- if !current_command.is_empty()
1716
- && (current_command == expected_provider_label
1717
- || current_command.contains(expected_provider_label))
1718
- {
1719
- return LeaderProviderHealth::Alive;
1720
- }
1721
- // Current command is NOT the provider — likely fell back to shell.
1722
- let is_shell = is_interactive_shell_basename(&current_command);
1723
- if is_shell {
1724
- // CR R6: marker text from single-source `leader_provider_exit_marker`
1725
- // so the wrapper printf and the health-check substring cannot drift.
1726
- let exit_marker_substr =
1727
- crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
1728
- if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
1729
- if cap.text.contains(&exit_marker_substr) {
1730
- return LeaderProviderHealth::ProviderExited;
1731
- }
1732
- }
1681
+ if matches!(transport.provider_exit_status(pane_id), Ok(Some(_))) {
1682
+ return LeaderProviderHealth::ProviderExited;
1733
1683
  }
1734
- // Conservative default — pane addressable, but couldn't positively
1735
- // confirm provider exit. Treat as Alive.
1684
+ // Missing/unsupported receipts (including old wrappers) are not proof of exit.
1736
1685
  LeaderProviderHealth::Alive
1737
1686
  }
1738
1687
 
@@ -1750,7 +1699,6 @@ pub enum LeaderProviderHealth {
1750
1699
 
1751
1700
  /// 0.4.x (CR R6 + R3): single-source shell-tail detection.
1752
1701
  /// Used by:
1753
- /// - `leader_provider_health` to decide "pane is at the shell tail"
1754
1702
  /// - shutdown logic to recognise a leader pane in shell-tail mode
1755
1703
  /// as still owned by the leader (not stray).
1756
1704
  ///
@@ -1859,6 +1807,17 @@ fn persist_managed_leader_binding(
1859
1807
  &serde_json::json!(socket),
1860
1808
  );
1861
1809
  let bound_panes = merge_bound_panes(existing_recv.as_ref(), new_binding);
1810
+ let existing_owner = state
1811
+ .get("teams")
1812
+ .and_then(|teams| teams.get(&team_key))
1813
+ .and_then(|team| team.get("team_owner"))
1814
+ .cloned();
1815
+ let keep_field = |existing: Option<&serde_json::Value>, key: &str, fallback: serde_json::Value| {
1816
+ existing
1817
+ .and_then(|value| value.get(key))
1818
+ .cloned()
1819
+ .unwrap_or(fallback)
1820
+ };
1862
1821
  let (scalar_pane, scalar_session, scalar_window, scalar_attached_at) =
1863
1822
  match existing_recv.as_ref() {
1864
1823
  Some(recv) if keep_existing_scalar => (
@@ -1867,20 +1826,41 @@ fn persist_managed_leader_binding(
1867
1826
  .unwrap_or(pane.as_str())
1868
1827
  .to_string(),
1869
1828
  recv.get("session_name")
1870
- .and_then(serde_json::Value::as_str)
1871
- .unwrap_or(session.as_str())
1872
- .to_string(),
1829
+ .cloned()
1830
+ .unwrap_or_else(|| serde_json::json!(session)),
1873
1831
  recv.get("window_name")
1874
- .and_then(serde_json::Value::as_str)
1875
- .unwrap_or(window.as_str())
1876
- .to_string(),
1832
+ .cloned()
1833
+ .unwrap_or_else(|| serde_json::json!(window)),
1877
1834
  recv.get("attached_at")
1878
1835
  .and_then(serde_json::Value::as_str)
1879
1836
  .unwrap_or(now.as_str())
1880
1837
  .to_string(),
1881
1838
  ),
1882
- _ => (pane.clone(), session.clone(), window.clone(), now.clone()),
1839
+ _ => (
1840
+ pane.clone(),
1841
+ serde_json::json!(session),
1842
+ serde_json::json!(window),
1843
+ now.clone(),
1844
+ ),
1883
1845
  };
1846
+ let leader_session_uuid = if keep_existing_scalar {
1847
+ keep_field(
1848
+ existing_recv.as_ref(),
1849
+ "leader_session_uuid",
1850
+ serde_json::json!(identity.leader_session_uuid),
1851
+ )
1852
+ } else {
1853
+ serde_json::json!(identity.leader_session_uuid)
1854
+ };
1855
+ let discovery = if keep_existing_scalar {
1856
+ keep_field(
1857
+ existing_recv.as_ref(),
1858
+ "discovery",
1859
+ serde_json::json!("managed_launcher"),
1860
+ )
1861
+ } else {
1862
+ serde_json::json!("managed_launcher")
1863
+ };
1884
1864
  let receiver = serde_json::json!({
1885
1865
  "mode": "direct_tmux",
1886
1866
  "status": "attached",
@@ -1890,20 +1870,43 @@ fn persist_managed_leader_binding(
1890
1870
  "session_name": scalar_session,
1891
1871
  "window_name": scalar_window,
1892
1872
  "tmux_socket": socket,
1893
- "leader_session_uuid": identity.leader_session_uuid,
1873
+ "leader_session_uuid": leader_session_uuid.clone(),
1894
1874
  "owner_epoch": owner_epoch,
1895
1875
  "attached_at": scalar_attached_at,
1896
- "discovery": "managed_launcher",
1876
+ "discovery": discovery,
1897
1877
  "bound_panes": bound_panes,
1898
1878
  });
1879
+ let owner_uuid = if keep_existing_scalar {
1880
+ keep_field(
1881
+ existing_owner.as_ref(),
1882
+ "leader_session_uuid",
1883
+ leader_session_uuid.clone(),
1884
+ )
1885
+ } else {
1886
+ leader_session_uuid.clone()
1887
+ };
1888
+ let claimed_via = if keep_existing_scalar {
1889
+ keep_field(
1890
+ existing_owner.as_ref(),
1891
+ "claimed_via",
1892
+ serde_json::json!("claim-leader"),
1893
+ )
1894
+ } else {
1895
+ serde_json::json!("claim-leader")
1896
+ };
1897
+ let claimed_at = if keep_existing_scalar {
1898
+ keep_field(existing_owner.as_ref(), "claimed_at", serde_json::json!(now))
1899
+ } else {
1900
+ serde_json::json!(now)
1901
+ };
1899
1902
  let owner = serde_json::json!({
1900
1903
  "pane_id": scalar_pane,
1901
1904
  "provider": provider.clone(),
1902
1905
  "machine_fingerprint": identity.machine_fingerprint,
1903
- "leader_session_uuid": identity.leader_session_uuid,
1906
+ "leader_session_uuid": owner_uuid,
1904
1907
  "owner_epoch": owner_epoch,
1905
- "claimed_at": now,
1906
- "claimed_via": "claim-leader",
1908
+ "claimed_at": claimed_at,
1909
+ "claimed_via": claimed_via,
1907
1910
  "os_user": identity.os_user,
1908
1911
  });
1909
1912
  if let Some(obj) = state.as_object_mut() {
@@ -2946,6 +2949,7 @@ mod tests {
2946
2949
  screens: Mutex<Vec<String>>,
2947
2950
  sent: Mutex<Vec<(Target, Vec<Key>)>>,
2948
2951
  liveness: PaneLiveness,
2952
+ exit_status: Option<u8>,
2949
2953
  targets: Vec<PaneInfo>,
2950
2954
  window_options: Mutex<Vec<(String, String)>>,
2951
2955
  window_option_error: Mutex<Option<String>>,
@@ -2959,6 +2963,7 @@ mod tests {
2959
2963
  screens: Mutex::new(screens),
2960
2964
  sent: Mutex::new(Vec::new()),
2961
2965
  liveness: PaneLiveness::Unknown,
2966
+ exit_status: None,
2962
2967
  targets: Vec::new(),
2963
2968
  window_options: Mutex::new(Vec::new()),
2964
2969
  window_option_error: Mutex::new(None),
@@ -2972,6 +2977,7 @@ mod tests {
2972
2977
  screens: Mutex::new(Vec::new()),
2973
2978
  sent: Mutex::new(Vec::new()),
2974
2979
  liveness,
2980
+ exit_status: None,
2975
2981
  targets: Vec::new(),
2976
2982
  window_options: Mutex::new(Vec::new()),
2977
2983
  window_option_error: Mutex::new(None),
@@ -2985,6 +2991,7 @@ mod tests {
2985
2991
  screens: Mutex::new(Vec::new()),
2986
2992
  sent: Mutex::new(Vec::new()),
2987
2993
  liveness,
2994
+ exit_status: None,
2988
2995
  targets,
2989
2996
  window_options: Mutex::new(Vec::new()),
2990
2997
  window_option_error: Mutex::new(None),
@@ -3000,6 +3007,7 @@ mod tests {
3000
3007
  screens: Mutex::new(Vec::new()),
3001
3008
  sent: Mutex::new(Vec::new()),
3002
3009
  liveness: PaneLiveness::Unknown,
3010
+ exit_status: None,
3003
3011
  targets: Vec::new(),
3004
3012
  window_options: Mutex::new(Vec::new()),
3005
3013
  window_option_error: Mutex::new(Some(error.to_string())),
@@ -3131,6 +3139,10 @@ mod tests {
3131
3139
  Ok(self.liveness)
3132
3140
  }
3133
3141
 
3142
+ fn provider_exit_status(&self, _pane: &PaneId) -> Result<Option<u8>, TransportError> {
3143
+ Ok(self.exit_status)
3144
+ }
3145
+
3134
3146
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
3135
3147
  Ok(self.targets.clone())
3136
3148
  }
@@ -3804,22 +3816,37 @@ mod tests {
3804
3816
  }
3805
3817
 
3806
3818
  #[test]
3807
- fn managed_attach_teardown_detects_provider_exit_marker() {
3819
+ fn managed_attach_teardown_requires_exit_receipt_not_replayed_markers() {
3808
3820
  let spawned = managed_spawn_result();
3809
- let transport = ScriptedTransport::new(vec![
3810
- "[team-agent] claude exited with 1\n[team-agent] Provider exited; this pane no longer accepts input.".to_string(),
3821
+ let mut transport = ScriptedTransport::new(vec![
3822
+ "[team-agent] pi exited with 1\n[team-agent] Provider exited".to_string(),
3811
3823
  ]);
3812
- let watch = super::ManagedAttachWatch {
3813
- transport: &transport,
3814
- spawned: &spawned,
3815
- session: &spawned.session,
3816
- session_existed_before: false,
3817
- workspace: Path::new("/tmp"),
3818
- provider_label: "claude",
3819
- };
3820
- assert!(
3821
- super::managed_attach_should_teardown(&watch),
3822
- "exit marker in capture must trigger teardown"
3824
+ transport.liveness = PaneLiveness::Live;
3825
+ for exit_status in [None, Some(0), Some(17)] {
3826
+ transport.exit_status = exit_status;
3827
+ let watch = super::ManagedAttachWatch {
3828
+ transport: &transport,
3829
+ spawned: &spawned,
3830
+ session: &spawned.session,
3831
+ session_existed_before: false,
3832
+ workspace: Path::new("/tmp"),
3833
+ provider_label: "pi",
3834
+ };
3835
+ assert_eq!(
3836
+ super::managed_attach_should_teardown(&watch),
3837
+ exit_status.is_some()
3838
+ );
3839
+ }
3840
+ assert_eq!(
3841
+ transport.screens.lock().unwrap().len(),
3842
+ 1,
3843
+ "health must not read history"
3844
+ );
3845
+ transport.liveness = PaneLiveness::Dead;
3846
+ transport.exit_status = None;
3847
+ assert_eq!(
3848
+ super::leader_provider_health(&transport, &spawned.pane_id, "pi"),
3849
+ super::LeaderProviderHealth::Unreachable
3823
3850
  );
3824
3851
  }
3825
3852
 
@@ -3881,18 +3908,22 @@ mod tests {
3881
3908
  }
3882
3909
 
3883
3910
  fn persist_test_identity(workspace: &Path) -> LeaderIdentity {
3911
+ persist_test_identity_named(workspace, "tester")
3912
+ }
3913
+
3914
+ fn persist_test_identity_named(workspace: &Path, os_user: &str) -> LeaderIdentity {
3884
3915
  LeaderIdentity {
3885
3916
  leader_session_uuid: LeaderSessionUuid::derive(
3886
3917
  "fp",
3887
3918
  &workspace.to_string_lossy(),
3888
- "tester",
3919
+ os_user,
3889
3920
  "current",
3890
3921
  )
3891
3922
  .unwrap(),
3892
3923
  leader_session_uuid_source: LeaderSessionUuidSource::Derived,
3893
3924
  machine_fingerprint: "fp".to_string(),
3894
3925
  workspace_abspath: workspace.to_path_buf(),
3895
- os_user: "tester".to_string(),
3926
+ os_user: os_user.to_string(),
3896
3927
  team_id: TeamKey::new("current"),
3897
3928
  }
3898
3929
  }
@@ -3995,6 +4026,84 @@ mod tests {
3995
4026
  let _ = std::fs::remove_dir_all(&workspace);
3996
4027
  }
3997
4028
 
4029
+ #[test]
4030
+ fn multi_leader_persist_keeps_first_owner_uuid() {
4031
+ let workspace = std::env::temp_dir().join(format!(
4032
+ "ta-multi-leader-uuid-{}",
4033
+ std::process::id()
4034
+ ));
4035
+ std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
4036
+ let original_uuid = "b2b230cfd645e78cf49e8147ba32ff3b";
4037
+ let state = serde_json::json!({
4038
+ "active_team_key": "current",
4039
+ "teams": {
4040
+ "current": {
4041
+ "leader_receiver": {
4042
+ "status": "attached",
4043
+ "pane_id": "%0",
4044
+ "discovery": "env_pane",
4045
+ "leader_session_uuid": original_uuid,
4046
+ "owner_epoch": 1,
4047
+ "attached_at": "2026-09-09T17:08:31.965311+00:00",
4048
+ "tmux_socket": "/private/tmp/tmux-501/ta-33bddc6e7687",
4049
+ "session_name": null,
4050
+ "provider": "codex"
4051
+ },
4052
+ "team_owner": {
4053
+ "pane_id": "%0",
4054
+ "leader_session_uuid": original_uuid,
4055
+ "owner_epoch": 1,
4056
+ "claimed_via": "quick-start"
4057
+ }
4058
+ }
4059
+ }
4060
+ });
4061
+ std::fs::write(
4062
+ crate::state::persist::runtime_state_path(&workspace),
4063
+ serde_json::to_vec_pretty(&state).unwrap(),
4064
+ )
4065
+ .unwrap();
4066
+ let mut plan = persist_test_plan(&workspace);
4067
+ plan.identity = Some(persist_test_identity_named(&workspace, "second-launcher"));
4068
+ let second_uuid = plan
4069
+ .identity
4070
+ .as_ref()
4071
+ .unwrap()
4072
+ .leader_session_uuid
4073
+ .as_str()
4074
+ .to_string();
4075
+ assert_ne!(second_uuid, original_uuid);
4076
+ let spawned = SpawnResult {
4077
+ pane_id: PaneId::new("%2"),
4078
+ session: SessionName::new(
4079
+ "team-agent-leader-codex-team-agent-live-0578-r4-19450903",
4080
+ ),
4081
+ window: WindowName::new("codex-second"),
4082
+ child_pid: Some(2),
4083
+ };
4084
+ persist_managed_leader_binding(&plan, &workspace, &spawned).expect("second persist");
4085
+ let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
4086
+ let recv = &loaded["teams"]["current"]["leader_receiver"];
4087
+ let owner = &loaded["teams"]["current"]["team_owner"];
4088
+ assert_eq!(recv["pane_id"], serde_json::json!("%0"));
4089
+ assert_eq!(recv["leader_session_uuid"], serde_json::json!(original_uuid));
4090
+ assert_eq!(recv["discovery"], serde_json::json!("env_pane"));
4091
+ assert_eq!(recv["session_name"], serde_json::Value::Null);
4092
+ assert_eq!(recv["owner_epoch"], serde_json::json!(1));
4093
+ assert_eq!(
4094
+ owner["leader_session_uuid"],
4095
+ serde_json::json!(original_uuid)
4096
+ );
4097
+ let panes = recv["bound_panes"].as_array().expect("bound_panes");
4098
+ let ids: Vec<&str> = panes
4099
+ .iter()
4100
+ .filter_map(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str))
4101
+ .collect();
4102
+ assert!(ids.contains(&"%0"), "bound_panes missing first: {recv}");
4103
+ assert!(ids.contains(&"%2"), "bound_panes missing second: {recv}");
4104
+ let _ = std::fs::remove_dir_all(&workspace);
4105
+ }
4106
+
3998
4107
  #[test]
3999
4108
  fn multi_leader_clear_promotes_remaining() {
4000
4109
  let workspace =
@@ -1,5 +1,8 @@
1
1
  use super::*;
2
2
 
3
+ #[path = "../../../tests/support/hermetic.rs"]
4
+ mod hermetic;
5
+
3
6
  // =====================================================================
4
7
  // 5. leader_session_name — sha1 派生 + 文件夹消毒(unimplemented → RED)
5
8
  // =====================================================================
@@ -111,18 +114,21 @@ fn owner_bind_refused_event_name_is_owner_bind_refused() {
111
114
  // old uuid 为 None → old_uuid_prefix == ""(空串,非缺省);全 32 hex uuid 绝不出现在任何字段。
112
115
  // unimplemented → RED。
113
116
  #[test]
117
+ #[serial_test::serial(env)]
114
118
  fn emit_owner_bound_event_logs_prefix_only_never_full_uuid() {
115
119
  let _lock = ENV_LOCK.lock().unwrap();
116
- let ws = std::env::temp_dir().join(format!("ta_rs_emit_{}", std::process::id()));
117
- std::fs::create_dir_all(&ws).unwrap();
118
- let _env = EnvGuard::apply(&[
119
- ("TMUX_PANE", Some("%7")),
120
- ("TEAM_AGENT_MACHINE_FINGERPRINT", Some("fp")),
121
- ("TEAM_AGENT_LEADER_PROVIDER", Some("codex")),
122
- ("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", None),
123
- ]);
120
+ let hermetic = hermetic::HermeticTestEnv::enter("owner-bound-uuid");
121
+ let ws = hermetic.workspace("emit");
122
+ let _pane = hermetic.with_env("TMUX_PANE", "%7");
123
+ let _fingerprint = hermetic.with_env("TEAM_AGENT_MACHINE_FINGERPRINT", "fp");
124
+ let _provider = hermetic.with_env("TEAM_AGENT_LEADER_PROVIDER", "codex");
124
125
  let result = bind_owner_from_caller_pane(&ws, &TeamKey::new("default"), None).unwrap();
125
- assert!(result.ok, "caller pane should produce an owner binding");
126
+ assert!(
127
+ result.ok,
128
+ "caller pane must produce an owner binding; reason={:?} hint={:?}",
129
+ result.reason,
130
+ result.hint
131
+ );
126
132
  let owner = result.owner.as_ref().expect("successful bind has owner");
127
133
  let full = owner
128
134
  .leader_session_uuid