@team-agent/installer 0.5.74 → 0.5.77

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/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +23 -96
  6. package/crates/team-agent/src/cli/emit.rs +35 -7
  7. package/crates/team-agent/src/cli/mod.rs +63 -42
  8. package/crates/team-agent/src/cli/models.rs +138 -27
  9. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  10. package/crates/team-agent/src/cli/status_port.rs +8 -0
  11. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  13. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  15. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  16. package/crates/team-agent/src/leader/lease.rs +274 -40
  17. package/crates/team-agent/src/leader/registry.rs +177 -19
  18. package/crates/team-agent/src/leader/types.rs +4 -0
  19. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +93 -5
  20. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1820 -60
  21. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  23. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
  24. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
  25. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  26. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
  27. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
  29. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  30. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  31. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  32. package/crates/team-agent/src/messaging/delivery.rs +200 -29
  33. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  34. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  35. package/crates/team-agent/src/state/paths.rs +17 -2
  36. package/crates/team-agent/src/state/persist.rs +166 -6
  37. package/crates/team-agent/src/state/projection.rs +18 -2
  38. package/crates/team-agent/src/state/repository.rs +36 -0
  39. package/crates/team-agent/src/state/selector.rs +23 -3
  40. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
  41. package/crates/team-agent/src/tmux_backend/tests.rs +350 -7
  42. package/crates/team-agent/src/tmux_backend.rs +217 -26
  43. package/crates/team-agent/src/transport/test_support.rs +18 -3
  44. package/crates/team-agent/src/transport.rs +99 -0
  45. package/package.json +4 -4
@@ -26,6 +26,46 @@ use super::{
26
26
 
27
27
  // ── leader::lease — attach / claim / takeover / autobind / readopt 统一 CAS 路径 ──
28
28
 
29
+ #[derive(Debug, Clone)]
30
+ pub(crate) struct AppliedLeaderGrant {
31
+ pub(crate) owner: Value,
32
+ pub(crate) receiver: Value,
33
+ }
34
+
35
+ #[derive(Debug, Default)]
36
+ struct AttachRecovery {
37
+ blocked_counts: crate::message_store::BlockedLeaderRequeueCounts,
38
+ }
39
+
40
+ #[derive(Debug)]
41
+ pub(crate) struct AttachLeaderError {
42
+ source: LeaderError,
43
+ applied_grant: Option<AppliedLeaderGrant>,
44
+ }
45
+
46
+ impl AttachLeaderError {
47
+ fn with_applied(source: LeaderError, applied_grant: Option<AppliedLeaderGrant>) -> Self {
48
+ Self {
49
+ source,
50
+ applied_grant,
51
+ }
52
+ }
53
+
54
+ pub(crate) fn into_parts(self) -> (LeaderError, Option<AppliedLeaderGrant>) {
55
+ (self.source, self.applied_grant)
56
+ }
57
+
58
+ fn into_error(self) -> LeaderError {
59
+ self.source
60
+ }
61
+ }
62
+
63
+ impl From<LeaderError> for AttachLeaderError {
64
+ fn from(source: LeaderError) -> Self {
65
+ Self::with_applied(source, None)
66
+ }
67
+ }
68
+
29
69
  /// `attach_leader`(card §42;`__init__.py:19`)。手动 CLI attach;持 `LEADER_OWNERSHIP_LOCK`
30
70
  /// 整段临界区做 state 变更 + 事件 + 双写 + requeue exhausted watchers。
31
71
  pub fn attach_leader(
@@ -91,8 +131,11 @@ pub fn attach_leader(
91
131
  if let Some(endpoint) = target.endpoint.as_deref() {
92
132
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
93
133
  }
94
- let _ =
134
+ let recovery =
95
135
  requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
136
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
137
+ let attach_window_failures =
138
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
96
139
  return Ok(LeaseResult {
97
140
  ok: true,
98
141
  status: LeaseStatus::Claimed,
@@ -106,6 +149,7 @@ pub fn attach_leader(
106
149
  .map(str::to_string),
107
150
  bound_pane_id: Some(pane_id),
108
151
  topology_convergence: None,
152
+ attach_window_failures,
109
153
  });
110
154
  }
111
155
  event_log.write(
@@ -130,7 +174,10 @@ pub fn attach_leader(
130
174
  if let Some(endpoint) = target.endpoint.as_deref() {
131
175
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
132
176
  }
133
- let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
177
+ let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
178
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
179
+ let attach_window_failures =
180
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
134
181
  return Ok(LeaseResult {
135
182
  ok: true,
136
183
  status: LeaseStatus::AlreadyBound,
@@ -141,6 +188,7 @@ pub fn attach_leader(
141
188
  action: None,
142
189
  bound_pane_id: Some(pane_id),
143
190
  topology_convergence: None,
191
+ attach_window_failures,
144
192
  });
145
193
  }
146
194
  let identity = leader_identity_context(workspace, None, Some(&state))?;
@@ -157,7 +205,10 @@ pub fn attach_leader(
157
205
  if let Some(endpoint) = target.endpoint.as_deref() {
158
206
  quiet_fake_leader_pane_echo(provider, &target.info, endpoint);
159
207
  }
160
- let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
208
+ let recovery = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
209
+ let team_id = TeamKey::new(crate::state::projection::team_state_key(&state));
210
+ let attach_window_failures =
211
+ attach_window_failure_value(recovery.blocked_counts, &team_id, &pane_id);
161
212
  Ok(LeaseResult {
162
213
  ok: true,
163
214
  status: LeaseStatus::Claimed,
@@ -168,6 +219,7 @@ pub fn attach_leader(
168
219
  action: None,
169
220
  bound_pane_id: Some(pane_id),
170
221
  topology_convergence: None,
222
+ attach_window_failures,
171
223
  })
172
224
  }
173
225
 
@@ -406,17 +458,34 @@ fn requeue_exhausted_watchers_after_attach(
406
458
  state: &Value,
407
459
  event_log: &crate::event_log::EventLog,
408
460
  pane_id: &PaneId,
409
- ) -> Result<Vec<crate::messaging::WatcherNotice>, LeaderError> {
461
+ ) -> Result<AttachRecovery, LeaderError> {
410
462
  let store = MessageStore::open(workspace)?;
411
463
  let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
412
- let notices = crate::messaging::requeue_delivery_exhausted_watchers(
413
- workspace, &store, event_log, &team_id, pane_id,
414
- )?;
464
+ let (notices, blocked_counts) =
465
+ crate::messaging::watchers::requeue_delivery_exhausted_watchers_with_counts(
466
+ workspace, &store, event_log, &team_id, pane_id,
467
+ )?;
415
468
  event_log.write(
416
469
  super::LeaderEvent::ReceiverRequeuedExhaustedWatchers.name(),
417
470
  requeued_exhausted_watchers_event_payload(pane_id, &team_id, &notices),
418
471
  )?;
419
- Ok(notices)
472
+ Ok(AttachRecovery { blocked_counts })
473
+ }
474
+
475
+ fn attach_window_failure_value(
476
+ counts: crate::message_store::BlockedLeaderRequeueCounts,
477
+ team_id: &TeamKey,
478
+ pane_id: &PaneId,
479
+ ) -> Option<Value> {
480
+ (counts.blocked_leader_unbound > 0).then(|| {
481
+ json!({
482
+ "team_id": team_id.as_str(),
483
+ "pane_id": pane_id.as_str(),
484
+ "reason": "leader_not_attached",
485
+ "count": counts.blocked_leader_unbound,
486
+ "status": "requeued_pending_physical_retry",
487
+ })
488
+ })
420
489
  }
421
490
 
422
491
  /// R8 D4 (decoupled for offline byte-lock — c-lite): build the `leader_receiver.requeued_exhausted_watchers`
@@ -454,63 +523,140 @@ pub(crate) fn attach_leader_to_state(
454
523
  source: LeaseSource,
455
524
  require_current: bool,
456
525
  ) -> Result<(LeaderReceiver, Value), LeaderError> {
457
- let _ = (source, require_current);
526
+ attach_leader_to_state_with_target_and_controls(
527
+ workspace,
528
+ state,
529
+ pane,
530
+ provider,
531
+ event_log,
532
+ source,
533
+ require_current,
534
+ None,
535
+ None,
536
+ None,
537
+ None,
538
+ )
539
+ .map_err(AttachLeaderError::into_error)
540
+ }
541
+
542
+ #[allow(clippy::too_many_arguments)]
543
+ pub(crate) fn attach_leader_to_state_with_target(
544
+ workspace: &Path,
545
+ state: &mut Value,
546
+ pane: Option<&PaneId>,
547
+ provider: Provider,
548
+ event_log: &crate::event_log::EventLog,
549
+ source: LeaseSource,
550
+ require_current: bool,
551
+ caller_target: Option<&PaneInfo>,
552
+ ) -> Result<(LeaderReceiver, Value), LeaderError> {
553
+ attach_leader_to_state_with_target_and_controls(
554
+ workspace,
555
+ state,
556
+ pane,
557
+ provider,
558
+ event_log,
559
+ source,
560
+ require_current,
561
+ caller_target,
562
+ None,
563
+ None,
564
+ None,
565
+ )
566
+ .map_err(AttachLeaderError::into_error)
567
+ }
568
+
569
+ #[allow(clippy::too_many_arguments)]
570
+ pub(crate) fn attach_leader_to_state_with_target_and_controls(
571
+ workspace: &Path,
572
+ state: &mut Value,
573
+ pane: Option<&PaneId>,
574
+ provider: Provider,
575
+ event_log: &crate::event_log::EventLog,
576
+ source: LeaseSource,
577
+ require_current: bool,
578
+ caller_target: Option<&PaneInfo>,
579
+ expected_owner: Option<&Value>,
580
+ expected_receiver: Option<&Value>,
581
+ nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
582
+ ) -> Result<(LeaderReceiver, Value), AttachLeaderError> {
583
+ if expected_owner.is_some() != expected_receiver.is_some() {
584
+ return Err(AttachLeaderError::from(LeaderError::Validation(
585
+ "fresh caller CAS requires both expected owner and receiver".to_string(),
586
+ )));
587
+ }
458
588
  let pane_id = pane
459
589
  .cloned()
460
590
  .ok_or_else(|| LeaderError::Validation("tmux pane not found".to_string()))?;
461
591
  let non_empty_pane_id = NonEmptyPaneId::try_from_pane(&pane_id)?;
462
592
  let identity = leader_identity_context(workspace, None, Some(state))?;
463
593
  let epoch = current_owner_epoch(state);
464
- let receiver = make_receiver(
594
+ let has_owner = state.get("team_owner").is_some();
595
+ let binding_epoch = if has_owner {
596
+ epoch
597
+ } else {
598
+ OwnerEpoch(epoch.0.saturating_add(1))
599
+ };
600
+ let mut receiver = make_receiver(
465
601
  provider,
466
602
  &non_empty_pane_id,
467
603
  &identity.leader_session_uuid,
468
- epoch,
604
+ binding_epoch,
469
605
  Discovery::EnvPane,
470
606
  None,
471
607
  );
472
- if state.get("team_owner").is_some() {
608
+ authorize_fresh_caller_receiver(
609
+ workspace,
610
+ &mut receiver,
611
+ &non_empty_pane_id,
612
+ source,
613
+ require_current,
614
+ caller_target,
615
+ nonce_writer,
616
+ )?;
617
+ let receiver_value = serde_json::to_value(&receiver).map_err(LeaderError::from)?;
618
+ if has_owner {
473
619
  write_receiver_to_state(state, &receiver)?;
474
620
  } else {
475
- let next_epoch = OwnerEpoch(epoch.0.saturating_add(1));
476
- let receiver = make_receiver(
477
- provider,
478
- &non_empty_pane_id,
479
- &identity.leader_session_uuid,
480
- next_epoch,
481
- Discovery::EnvPane,
482
- None,
483
- );
484
- let owner = make_owner(provider, &non_empty_pane_id, &identity, next_epoch);
621
+ let owner = make_owner(provider, &non_empty_pane_id, &identity, binding_epoch);
485
622
  write_binding_to_state(state, &receiver, &owner)?;
486
- write_lease_dual_state(workspace, state)?;
487
- event_log.write(
488
- super::LeaderEvent::ReceiverAttached.name(),
489
- json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
490
- )?;
491
- let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
492
- crate::messaging::watchers::recover_watchers_for_incident(
623
+ }
624
+ if let (Some(expected_owner), Some(expected_receiver)) = (expected_owner, expected_receiver) {
625
+ write_lease_dual_state_with_expected(
493
626
  workspace,
494
- event_log,
495
- &team_id,
496
- &pane_id,
497
- LeaderIncident::LeaderAttached,
627
+ state,
628
+ expected_owner,
629
+ expected_receiver,
498
630
  )?;
499
- return Ok((receiver, json!({"ok": true})));
631
+ } else {
632
+ write_lease_dual_state(workspace, state)?;
500
633
  }
501
- write_lease_dual_state(workspace, state)?;
502
- event_log.write(
634
+ let applied_grant = state
635
+ .get("team_owner")
636
+ .cloned()
637
+ .map(|owner| AppliedLeaderGrant {
638
+ owner,
639
+ receiver: receiver_value,
640
+ });
641
+ if let Err(error) = event_log.write(
503
642
  super::LeaderEvent::ReceiverAttached.name(),
504
- json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
505
- )?;
643
+ json!({"pane_id": pane_id.as_str(), "owner_epoch": binding_epoch.0}),
644
+ ) {
645
+ return Err(AttachLeaderError::with_applied(
646
+ error.into(),
647
+ applied_grant.clone(),
648
+ ));
649
+ }
506
650
  let team_id = TeamKey::new(crate::state::projection::team_state_key(state));
507
- crate::messaging::watchers::recover_watchers_for_incident(
651
+ if let Err(error) = crate::messaging::watchers::recover_watchers_for_incident(
508
652
  workspace,
509
653
  event_log,
510
654
  &team_id,
511
655
  &pane_id,
512
656
  LeaderIncident::LeaderAttached,
513
- )?;
657
+ ) {
658
+ return Err(AttachLeaderError::with_applied(error.into(), applied_grant));
659
+ }
514
660
  Ok((receiver, json!({"ok": true})))
515
661
  }
516
662
 
@@ -837,6 +983,7 @@ fn claim_lease_no_incident_with_target(
837
983
  action: None,
838
984
  bound_pane_id: Some(caller_pane.clone()),
839
985
  topology_convergence,
986
+ attach_window_failures: None,
840
987
  });
841
988
  }
842
989
  let owner_live = bound_pane_id.as_deref().is_some_and(|pane| {
@@ -1082,6 +1229,7 @@ fn claim_lease_no_incident_with_target(
1082
1229
  action: None,
1083
1230
  bound_pane_id: Some(caller_pane.clone()),
1084
1231
  topology_convergence,
1232
+ attach_window_failures: None,
1085
1233
  })
1086
1234
  }
1087
1235
 
@@ -1340,6 +1488,7 @@ fn convergence_persistence_refusal(
1340
1488
  ),
1341
1489
  bound_pane_id,
1342
1490
  topology_convergence,
1491
+ attach_window_failures: None,
1343
1492
  }
1344
1493
  }
1345
1494
 
@@ -1363,6 +1512,7 @@ fn refused(
1363
1512
  },
1364
1513
  bound_pane_id,
1365
1514
  topology_convergence: None,
1515
+ attach_window_failures: None,
1366
1516
  }
1367
1517
  }
1368
1518
 
@@ -1870,6 +2020,72 @@ fn emit_lease_refusal(
1870
2020
  Ok(())
1871
2021
  }
1872
2022
 
2023
+ fn authorize_fresh_caller_receiver(
2024
+ workspace: &Path,
2025
+ receiver: &mut LeaderReceiver,
2026
+ pane: &NonEmptyPaneId,
2027
+ source: LeaseSource,
2028
+ require_current: bool,
2029
+ caller_target: Option<&PaneInfo>,
2030
+ nonce_writer: Option<&dyn Fn(&str, &PaneId, &str) -> Result<String, LeaderError>>,
2031
+ ) -> Result<(), LeaderError> {
2032
+ if !matches!(source, LeaseSource::QuickStart) || !require_current {
2033
+ return Ok(());
2034
+ }
2035
+ let Some(target) = caller_target else {
2036
+ return Ok(());
2037
+ };
2038
+ if target.pane_id.as_str() != pane.as_pane_id().as_str() || !target.active {
2039
+ return Err(LeaderError::Validation(
2040
+ "fresh caller pane observation does not match the bound pane".to_string(),
2041
+ ));
2042
+ }
2043
+ let Some(current_path) = target.current_path.as_deref() else {
2044
+ return Ok(());
2045
+ };
2046
+ if crate::messaging::leader_channel::path_is_in_workspace(current_path, workspace) {
2047
+ return Ok(());
2048
+ }
2049
+ let Some(endpoint) = receiver
2050
+ .tmux_socket
2051
+ .as_deref()
2052
+ .filter(|endpoint| !endpoint.is_empty())
2053
+ else {
2054
+ return Err(LeaderError::Validation(
2055
+ "fresh cross-workspace caller requires a tmux endpoint".to_string(),
2056
+ ));
2057
+ };
2058
+ if !Path::new(endpoint).is_absolute() {
2059
+ return Err(LeaderError::Validation(
2060
+ "fresh cross-workspace caller requires an absolute tmux endpoint".to_string(),
2061
+ ));
2062
+ }
2063
+ let pane_id = pane.as_pane_id();
2064
+ let live_nonce = if let Some(live_nonce) = target
2065
+ .leader_env
2066
+ .get(crate::tmux_backend::PANE_BINDING_NONCE_METADATA_KEY)
2067
+ .filter(|nonce| !nonce.is_empty())
2068
+ .cloned()
2069
+ {
2070
+ live_nonce
2071
+ } else {
2072
+ let nonce = pane_binding_nonce_for_claim(workspace, endpoint, pane_id, None);
2073
+ if let Some(writer) = nonce_writer {
2074
+ writer(endpoint, pane_id, &nonce)?
2075
+ } else {
2076
+ tmux_backend_for_endpoint(endpoint)
2077
+ .set_pane_binding_nonce_if_unset(pane_id, &nonce)
2078
+ .map_err(|error| LeaderError::Tmux(error.to_string()))?
2079
+ }
2080
+ };
2081
+ receiver.scope_authority = Some(
2082
+ crate::messaging::leader_channel::FRESH_CALLER_SCOPE_AUTHORITY.to_string(),
2083
+ );
2084
+ receiver.authorized_team_workspace = Some(canonical_workspace(workspace));
2085
+ receiver.binding_nonce = Some(live_nonce);
2086
+ Ok(())
2087
+ }
2088
+
1873
2089
  fn make_receiver(
1874
2090
  provider: Provider,
1875
2091
  pane: &NonEmptyPaneId,
@@ -2237,6 +2453,24 @@ fn state_owner(state: &Value) -> Option<TeamOwner> {
2237
2453
  /// now persists ONLY the canonical root state; retaining the public
2238
2454
  /// name for 0.5.x call-site stability. The B0 legacy snapshot is
2239
2455
  /// diagnostic-only via `lifecycle::save_team_runtime_snapshot`.
2456
+ pub(crate) fn write_lease_dual_state_with_expected(
2457
+ workspace: &Path,
2458
+ state: &Value,
2459
+ expected_owner: &Value,
2460
+ expected_receiver: &Value,
2461
+ ) -> Result<(), LeaderError> {
2462
+ let team_key = canonical_owner_write_key(state);
2463
+ crate::state::repository::StateRepository::new(workspace).save(
2464
+ crate::state::repository::StateWriteIntent::ClaimLeaderFreshCaller {
2465
+ team_key: team_key.as_str(),
2466
+ expected_owner,
2467
+ expected_receiver,
2468
+ },
2469
+ state,
2470
+ )?;
2471
+ Ok(())
2472
+ }
2473
+
2240
2474
  pub fn write_lease_dual_state(workspace: &Path, state: &Value) -> Result<(), LeaderError> {
2241
2475
  // 0.5.42 S1b (s1b-writer-cluster-locate.md §4.4): terminal root
2242
2476
  // save routes through `StateRepository` with the `ClaimLeader`