@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
@@ -0,0 +1,144 @@
1
+ //! Scope and lifecycle boundaries for the abnormal-exit observation cache.
2
+ use std::collections::BTreeSet;
3
+
4
+ use serde_json::{json, Value};
5
+
6
+ fn members(state: &Value) -> Option<BTreeSet<String>> {
7
+ Some(state.get("agents")?.as_object()?.keys().cloned().collect())
8
+ }
9
+
10
+ // A missing roster is unknown. Only a complete, lock-merged roster authorizes
11
+ // discarding absent members; liveness, pause status and record age do not.
12
+ fn scopes(state: &Value) -> Vec<(Option<String>, Option<BTreeSet<String>>)> {
13
+ let mut root = members(state);
14
+ if state.get("teams").is_some_and(|teams| !teams.is_object()) {
15
+ root = None;
16
+ }
17
+ let mut scopes = Vec::new();
18
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
19
+ for (team, entry) in teams {
20
+ let roster = members(entry);
21
+ root = match (root, &roster) {
22
+ (Some(mut union), Some(keys)) => {
23
+ union.extend(keys.iter().cloned());
24
+ Some(union)
25
+ }
26
+ _ => None,
27
+ };
28
+ scopes.push((Some(team.clone()), roster));
29
+ }
30
+ }
31
+ scopes.push((None, root));
32
+ scopes
33
+ }
34
+
35
+ pub(crate) fn needs_compaction(state: &Value) -> bool {
36
+ scopes(state).into_iter().any(|(team, roster)| {
37
+ let entry = team.as_ref().map_or(state, |key| &state["teams"][key]);
38
+ roster.is_some_and(|keys| {
39
+ entry
40
+ .pointer("/coordinator/abnormal_exit_watch")
41
+ .and_then(Value::as_object)
42
+ .is_some_and(|watch| watch.keys().any(|key| !keys.contains(key)))
43
+ })
44
+ })
45
+ }
46
+
47
+ /// Call only after the persistence lock's latest-roster merge.
48
+ pub(crate) fn compact_after_merge(state: &mut Value, latest: Option<&Value>, deleted: &[&str]) {
49
+ let latest_scopes = latest.map(scopes).unwrap_or_default();
50
+ for (team, mut roster) in scopes(state) {
51
+ // The normal merge may omit a roster row with no allowlisted stub
52
+ // fields, or a whole sibling absent from a stale input. Membership in
53
+ // lock-held latest still forbids pruning its observation. Only explicit
54
+ // deletion authority can subtract it from this retention union.
55
+ if let Some((_, latest_roster)) = latest_scopes.iter().find(|(key, _)| key == &team) {
56
+ roster = match (roster, latest_roster) {
57
+ (Some(mut keys), Some(latest_keys)) => {
58
+ keys.extend(
59
+ latest_keys
60
+ .iter()
61
+ .filter(|key| !deleted.contains(&key.as_str()))
62
+ .cloned(),
63
+ );
64
+ Some(keys)
65
+ }
66
+ _ => None,
67
+ };
68
+ }
69
+ let Some(keys) = roster else { continue };
70
+ let entry = match team {
71
+ Some(team) => &mut state["teams"][&team],
72
+ None => &mut *state,
73
+ };
74
+ if let Some(watch) = entry
75
+ .pointer_mut("/coordinator/abnormal_exit_watch")
76
+ .and_then(Value::as_object_mut)
77
+ {
78
+ watch.retain(|key, _| keys.contains(key));
79
+ }
80
+ }
81
+ }
82
+
83
+ /// New observations carry their team and cohort, never just a bare agent id.
84
+ pub(crate) fn observation_identity(state: &Value, agent_id: &str) -> Option<Value> {
85
+ let agent = state.get("agents")?.get(agent_id)?;
86
+ let provider = crate::provider::wire::parse_provider(agent.get("provider")?.as_str()?)?;
87
+ let path = ["rollout_path", "transcript_path", "session_log_path"]
88
+ .into_iter()
89
+ .find_map(|key| agent.get(key).and_then(Value::as_str))
90
+ .filter(|path| !path.is_empty())?;
91
+ let spawn_epoch = agent.get("spawn_epoch").and_then(Value::as_u64);
92
+ let spawned_at = agent
93
+ .get("spawned_at")
94
+ .and_then(Value::as_str)
95
+ .filter(|value| !value.is_empty());
96
+ // Preserve the watcher's existing legacy cohort: missing spawn fields
97
+ // remain explicit nulls, never a fabricated generation. A later known
98
+ // cohort changes this identity and establishes a new baseline.
99
+ let team = state
100
+ .get("active_team_key")
101
+ .and_then(Value::as_str)
102
+ .filter(|team| !team.is_empty())
103
+ .map(str::to_string)
104
+ .or_else(|| {
105
+ let team = super::projection::team_state_key(state);
106
+ (team != super::projection::CURRENT_TEAM_ALIAS).then_some(team)
107
+ })?;
108
+ Some(json!({
109
+ "team_key": team,
110
+ "agent_id": agent_id,
111
+ "provider": crate::provider::wire::provider_wire(provider),
112
+ "path": path,
113
+ "spawn_epoch": spawn_epoch,
114
+ "spawned_at": spawned_at,
115
+ }))
116
+ }
117
+
118
+ pub(crate) fn matches_observation(state: &Value, agent_id: &str, row: &Value) -> bool {
119
+ observation_identity(state, agent_id)
120
+ .is_some_and(|identity| row.get("watch_identity") == Some(&identity))
121
+ }
122
+
123
+ /// Legacy/foreign-cohort facts cannot be reused as current observation evidence.
124
+ /// This is invoked only when actually observing the member, not for paused or
125
+ /// otherwise ineligible members. The next scan establishes the ordinary baseline.
126
+ pub(crate) fn begin_observation(state: &mut Value, agent_id: &str) {
127
+ let identity = observation_identity(state, agent_id);
128
+ if let Some(watch) = state
129
+ .pointer_mut("/coordinator/abnormal_exit_watch")
130
+ .and_then(Value::as_object_mut)
131
+ {
132
+ if watch.get(agent_id).is_some_and(|row| {
133
+ identity
134
+ .as_ref()
135
+ .is_none_or(|identity| row.get("watch_identity") != Some(identity))
136
+ }) {
137
+ watch.remove(agent_id);
138
+ }
139
+ }
140
+ }
141
+
142
+ #[cfg(test)]
143
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
144
+ mod tests;
@@ -15,6 +15,7 @@
15
15
  //! 绝不 in-place truncate,绝不让审计/重试失败拖垮可见的原 state。
16
16
  #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
17
17
 
18
+ pub(crate) mod abnormal_watch;
18
19
  pub mod identity;
19
20
  // Stage 0 of identity-boundary unified plan (architect direction 2026-06-23):
20
21
  // shared identity primitives. Pure additive — Stage 1+ consume these.
@@ -377,6 +377,7 @@ fn save_runtime_state_with_merge_options(
377
377
  exact_owner_seed_to_clear,
378
378
  None,
379
379
  None,
380
+ None,
380
381
  )
381
382
  }
382
383
 
@@ -399,6 +400,7 @@ pub(crate) fn save_runtime_state_with_receiver_authority_and_expected(
399
400
  None,
400
401
  Some((expected_owner, expected_receiver)),
401
402
  None,
403
+ None,
402
404
  )
403
405
  }
404
406
 
@@ -421,6 +423,38 @@ pub(crate) fn save_runtime_state_with_exact_owner_receiver_cleanup(
421
423
  None,
422
424
  None,
423
425
  Some((team_key, expected_owner, expected_receiver)),
426
+ None,
427
+ )
428
+ }
429
+
430
+ pub(crate) fn save_runtime_state_with_exact_owner_receiver_restore(
431
+ workspace: &Path,
432
+ state: &Value,
433
+ team_key: &str,
434
+ expected_owner: &Value,
435
+ expected_receiver: &Value,
436
+ previous_owner: &Value,
437
+ previous_receiver: &Value,
438
+ ) -> Result<(), StateError> {
439
+ persist_runtime_state_with_merge_options_and_expected(
440
+ workspace,
441
+ state,
442
+ &[],
443
+ None,
444
+ &[],
445
+ None,
446
+ &[],
447
+ Some(team_key),
448
+ None,
449
+ None,
450
+ None,
451
+ Some((
452
+ team_key,
453
+ expected_owner,
454
+ expected_receiver,
455
+ previous_owner,
456
+ previous_receiver,
457
+ )),
424
458
  )
425
459
  }
426
460
 
@@ -436,6 +470,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
436
470
  exact_owner_seed_to_clear: Option<&Value>,
437
471
  expected_owner_receiver: Option<(&Value, &Value)>,
438
472
  exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
473
+ exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
439
474
  ) -> Result<(), StateError> {
440
475
  let path = runtime_state_path(workspace);
441
476
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
@@ -457,9 +492,11 @@ fn persist_runtime_state_with_merge_options_and_expected(
457
492
  // Exact-seed cleanup must observe lock-held disk, not a pre-lock cache or
458
493
  // byte-equality hit. A matching in-memory document would otherwise no-op
459
494
  // and leave the seed, or skip copying a concurrent owner.
460
- let skip_pre_lock_fast_path = exact_owner_seed_to_clear.is_some()
495
+ let skip_pre_lock_fast_path = super::abnormal_watch::needs_compaction(&migrated)
496
+ || exact_owner_seed_to_clear.is_some()
461
497
  || expected_owner_receiver.is_some()
462
- || exact_owner_receiver_to_clear.is_some();
498
+ || exact_owner_receiver_to_clear.is_some()
499
+ || exact_owner_receiver_to_restore.is_some();
463
500
  if !skip_pre_lock_fast_path && cache_equals(&path, &migrated) {
464
501
  return Ok(());
465
502
  }
@@ -506,7 +543,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
506
543
  ));
507
544
  }
508
545
  }
509
- if let Some(latest) = latest {
546
+ if let Some(latest) = latest.as_ref() {
510
547
  let deleted = deleted_agent_ids
511
548
  .iter()
512
549
  .copied()
@@ -536,7 +573,7 @@ fn persist_runtime_state_with_merge_options_and_expected(
536
573
  .collect::<BTreeSet<_>>();
537
574
  apply_persist_merge_contract(
538
575
  &mut migrated,
539
- &latest,
576
+ latest,
540
577
  &deleted,
541
578
  skip_capture_backfill_team_key,
542
579
  &skip_capture_backfill,
@@ -544,8 +581,10 @@ fn persist_runtime_state_with_merge_options_and_expected(
544
581
  &receiver_updates,
545
582
  receiver_update_team_key.zip(exact_owner_seed_to_clear),
546
583
  exact_owner_receiver_to_clear,
584
+ exact_owner_receiver_to_restore,
547
585
  )?;
548
586
  }
587
+ super::abnormal_watch::compact_after_merge(&mut migrated, latest.as_ref(), deleted_agent_ids);
549
588
  // Stage 3 save-output strip second pass (defence-in-depth): after the
550
589
  // `preserve_latest_roster_entries` lock-held merge, a future addition
551
590
  // to the preserve path could re-introduce root owner fields from the
@@ -613,6 +652,7 @@ fn apply_persist_merge_contract(
613
652
  receiver_updates: &BTreeSet<String>,
614
653
  exact_owner_seed_to_clear: Option<(&str, &Value)>,
615
654
  exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
655
+ exact_owner_receiver_to_restore: Option<(&str, &Value, &Value, &Value, &Value)>,
616
656
  ) -> Result<(), StateError> {
617
657
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
618
658
  // the top-level<->active-team cross projections depend on which team is active); the
@@ -745,6 +785,25 @@ fn apply_persist_merge_contract(
745
785
  );
746
786
  }
747
787
  }
788
+ if let Some((
789
+ restore_team,
790
+ expected_owner,
791
+ expected_receiver,
792
+ previous_owner,
793
+ previous_receiver,
794
+ )) = exact_owner_receiver_to_restore
795
+ {
796
+ if team == restore_team {
797
+ apply_lock_held_exact_owner_receiver_restore(
798
+ incoming_entry,
799
+ latest_entry,
800
+ expected_owner,
801
+ expected_receiver,
802
+ previous_owner,
803
+ previous_receiver,
804
+ );
805
+ }
806
+ }
748
807
  preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
749
808
  }
750
809
  }
@@ -919,6 +978,43 @@ fn apply_lock_held_exact_owner_receiver_cleanup(
919
978
  }
920
979
  }
921
980
 
981
+ fn apply_lock_held_exact_owner_receiver_restore(
982
+ incoming: &mut Value,
983
+ latest: &Value,
984
+ expected_owner: &Value,
985
+ expected_receiver: &Value,
986
+ previous_owner: &Value,
987
+ previous_receiver: &Value,
988
+ ) {
989
+ let latest_owner = latest.get("team_owner");
990
+ let latest_receiver = latest.get("leader_receiver");
991
+ let Some(incoming_obj) = incoming.as_object_mut() else {
992
+ return;
993
+ };
994
+ if latest_owner == Some(expected_owner) && latest_receiver == Some(expected_receiver) {
995
+ incoming_obj.insert("team_owner".to_string(), previous_owner.clone());
996
+ incoming_obj.insert("leader_receiver".to_string(), previous_receiver.clone());
997
+ if let Some(epoch) = previous_owner
998
+ .get("owner_epoch")
999
+ .cloned()
1000
+ .or_else(|| latest.get("owner_epoch").cloned())
1001
+ {
1002
+ incoming_obj.insert("owner_epoch".to_string(), epoch);
1003
+ }
1004
+ return;
1005
+ }
1006
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
1007
+ match latest.get(key) {
1008
+ Some(value) => {
1009
+ incoming_obj.insert(key.to_string(), value.clone());
1010
+ }
1011
+ None => {
1012
+ incoming_obj.remove(key);
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+
922
1018
  fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
923
1019
  if !latest_has_preferable_endpoint_convergence(incoming, latest) {
924
1020
  return;
@@ -3113,6 +3209,7 @@ mod tests {
3113
3209
  &BTreeSet::new(),
3114
3210
  None,
3115
3211
  None,
3212
+ None,
3116
3213
  )
3117
3214
  .unwrap();
3118
3215
 
@@ -3149,6 +3246,7 @@ mod tests {
3149
3246
  &receiver_updates,
3150
3247
  None,
3151
3248
  None,
3249
+ None,
3152
3250
  )
3153
3251
  .unwrap();
3154
3252
 
@@ -486,7 +486,13 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
486
486
  .is_some_and(|o| o.contains_key("coordinator"))
487
487
  && !p.contains_key("coordinator")
488
488
  {
489
- p.insert("coordinator".to_string(), state["coordinator"].clone());
489
+ let mut coordinator = state["coordinator"].clone();
490
+ // Workspace legacy watch facts have no proven owner in this team.
491
+ // Preserve other coordinator fields, but never broadcast this cache.
492
+ if let Some(coordinator) = coordinator.as_object_mut() {
493
+ coordinator.remove("abnormal_exit_watch");
494
+ }
495
+ p.insert("coordinator".to_string(), coordinator);
490
496
  }
491
497
  Value::Object(p)
492
498
  }
@@ -52,6 +52,8 @@ use super::persist::{
52
52
  as helper_write_root_with_receiver_authority_and_expected,
53
53
  save_runtime_state_with_exact_owner_receiver_cleanup
54
54
  as helper_write_root_with_exact_owner_receiver_cleanup,
55
+ save_runtime_state_with_exact_owner_receiver_restore
56
+ as helper_write_root_with_exact_owner_receiver_restore,
55
57
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
56
58
  save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
57
59
  save_runtime_state_without_migrations as helper_write_root_without_migrations,
@@ -224,9 +226,20 @@ pub enum StateWriteIntent<'a> {
224
226
  seed: &'a Value,
225
227
  expected_receiver: &'a Value,
226
228
  },
229
+ RestoreExactTeamOwnerAndReceiver {
230
+ team_key: &'a str,
231
+ expected_owner: &'a Value,
232
+ expected_receiver: &'a Value,
233
+ previous_owner: &'a Value,
234
+ previous_receiver: &'a Value,
235
+ },
227
236
  LeaderBindingRestoreNonTargetTeams {
228
237
  target_team_key: &'a str,
229
238
  },
239
+ /// Strip extra keys written by a failed or degraded quick-start bind attempt.
240
+ QuickStartBindingCleanup {
241
+ team_key: &'a str,
242
+ },
230
243
  LeaderStartBinding {
231
244
  team_key: &'a str,
232
245
  transport_kind: &'a str,
@@ -428,6 +441,22 @@ fn route_direct(
428
441
  seed,
429
442
  expected_receiver,
430
443
  ),
444
+ StateWriteIntent::RestoreExactTeamOwnerAndReceiver {
445
+ team_key,
446
+ expected_owner,
447
+ expected_receiver,
448
+ previous_owner,
449
+ previous_receiver,
450
+ } => helper_write_root_with_exact_owner_receiver_restore(
451
+ workspace,
452
+ state,
453
+ team_key,
454
+ expected_owner,
455
+ expected_receiver,
456
+ previous_owner,
457
+ previous_receiver,
458
+ ),
459
+ StateWriteIntent::QuickStartBindingCleanup { .. } => helper_write_root(workspace, state),
431
460
  StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
432
461
  helper_write_root_without_migrations(workspace, state)
433
462
  }
@@ -1,16 +1,33 @@
1
1
  use std::fs::OpenOptions;
2
- use std::io::{Read, Write};
3
- use std::os::unix::net::{UnixListener, UnixStream};
2
+ use std::io::Write;
4
3
  use std::path::{Path, PathBuf};
5
4
  use std::process::{Command, Stdio};
6
- use std::time::Duration;
5
+ use std::time::{Duration, Instant};
7
6
 
8
7
  const CHILD_FLAG: &str = "--team-agent-models-timeout-child";
9
8
  const LIST_MODELS_FLAG: &str = "--list-models";
10
9
  const RECEIPT_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_RECEIPT";
10
+ const STAGE_RECEIPT_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_STAGE_RECEIPT";
11
11
  const MODE_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_MODE";
12
- const READY_SOCKET_ENV: &str = "TEAM_AGENT_MODELS_TIMEOUT_READY_SOCKET";
13
12
  const STDOUT_MARKER: &[u8] = b"__team_agent_models_timeout_descendant_stdout_v1__\n";
13
+ const PARENT_HANDOFF_TIMEOUT: Duration = Duration::from_secs(1);
14
+
15
+ // Stage values are UNIX epoch milliseconds so the test process and helper use
16
+ // one directly comparable clock. Stage writes are best-effort diagnostics and
17
+ // never change the fixture's production-facing exit semantics.
18
+ fn append_stage(key: &str) {
19
+ let Some(path) = std::env::var_os(STAGE_RECEIPT_ENV) else {
20
+ return;
21
+ };
22
+ let Some(milliseconds) = std::time::SystemTime::now()
23
+ .duration_since(std::time::UNIX_EPOCH)
24
+ .ok()
25
+ .map(|duration| duration.as_millis())
26
+ else {
27
+ return;
28
+ };
29
+ let _ = append_receipt(Path::new(&path), key, milliseconds);
30
+ }
14
31
 
15
32
  fn receipt_path() -> PathBuf {
16
33
  std::env::var_os(RECEIPT_ENV)
@@ -27,14 +44,29 @@ fn append_receipt(path: &Path, key: &str, value: impl std::fmt::Display) -> Resu
27
44
  writeln!(receipt, "{key}={value}").map_err(|_| ())
28
45
  }
29
46
 
47
+ fn wait_for_parent_handoff(receipt: &Path) {
48
+ let expected = format!("child_pid={}", std::process::id());
49
+ let deadline = Instant::now() + PARENT_HANDOFF_TIMEOUT;
50
+ loop {
51
+ if std::fs::read_to_string(receipt)
52
+ .ok()
53
+ .is_some_and(|text| text.lines().any(|line| line == expected))
54
+ {
55
+ return;
56
+ }
57
+ if Instant::now() >= deadline {
58
+ std::process::exit(5);
59
+ }
60
+ std::thread::sleep(Duration::from_millis(1));
61
+ }
62
+ }
63
+
30
64
  fn child_main(args: &[String]) {
31
65
  if args.len() != 1 || args[0] != CHILD_FLAG {
32
66
  std::process::exit(5);
33
67
  }
34
68
  let receipt = receipt_path();
35
- let socket = std::env::var_os(READY_SOCKET_ENV)
36
- .map(PathBuf::from)
37
- .unwrap_or_else(|| std::process::exit(5));
69
+ wait_for_parent_handoff(&receipt);
38
70
 
39
71
  let mut stdout = std::io::stdout();
40
72
  if stdout.write_all(STDOUT_MARKER).is_err() || stdout.flush().is_err() {
@@ -48,10 +80,6 @@ fn child_main(args: &[String]) {
48
80
  std::process::exit(5);
49
81
  }
50
82
 
51
- let mut ready = UnixStream::connect(socket).unwrap_or_else(|_| std::process::exit(5));
52
- if ready.write_all(b"1").is_err() {
53
- std::process::exit(5);
54
- }
55
83
  std::thread::sleep(Duration::from_secs(2));
56
84
  }
57
85
 
@@ -86,10 +114,8 @@ fn main() {
86
114
  Ok(_) | Err(std::env::VarError::NotUnicode(_)) => std::process::exit(2),
87
115
  }
88
116
 
117
+ append_stage("parent_entry_ms");
89
118
  let receipt = receipt_path();
90
- let socket = PathBuf::from(format!("{}.sock", receipt.display()));
91
- let _ = std::fs::remove_file(&socket);
92
- let listener = UnixListener::bind(&socket).unwrap_or_else(|_| std::process::exit(4));
93
119
  if append_receipt(&receipt, "parent_started_once", 1).is_err()
94
120
  || append_receipt(&receipt, "parent_argv_exact", 1).is_err()
95
121
  || append_receipt(&receipt, "parent_pid", std::process::id()).is_err()
@@ -99,24 +125,20 @@ fn main() {
99
125
  }
100
126
 
101
127
  let executable = std::env::current_exe().unwrap_or_else(|_| std::process::exit(4));
128
+ append_stage("parent_before_child_spawn_ms");
102
129
  let child = Command::new(executable)
103
130
  .arg(CHILD_FLAG)
104
131
  .env(RECEIPT_ENV, &receipt)
105
- .env(READY_SOCKET_ENV, &socket)
106
132
  .stdin(Stdio::null())
107
133
  .stdout(Stdio::inherit())
108
134
  .stderr(Stdio::null())
109
135
  .spawn()
110
136
  .unwrap_or_else(|_| std::process::exit(4));
111
- let (mut ready, _) = listener.accept().unwrap_or_else(|_| std::process::exit(4));
112
- let mut byte = [0_u8; 1];
113
- if ready.read_exact(&mut byte).is_err() || byte != [b'1'] {
114
- std::process::exit(4);
115
- }
137
+ append_stage("parent_after_child_spawn_ms");
116
138
  if append_receipt(&receipt, "child_pid", child.id()).is_err() {
117
139
  std::process::exit(4);
118
140
  }
119
- let _ = std::fs::remove_file(&socket);
141
+ append_stage("parent_before_exit_ms");
120
142
  // The parent exits successfully without waiting; the child retains the
121
143
  // inherited stdout pipe and self-exits after the real reader deadline.
122
144
  }