@team-agent/installer 0.5.52 → 0.5.54

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 (87) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +111 -3
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  7. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  8. package/crates/team-agent/src/cli/send.rs +1 -0
  9. package/crates/team-agent/src/cli/spec.rs +4 -1
  10. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +12 -0
  15. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  16. package/crates/team-agent/src/db/message_store.rs +31 -2
  17. package/crates/team-agent/src/db/migration.rs +7 -6
  18. package/crates/team-agent/src/db/schema.rs +18 -5
  19. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  20. package/crates/team-agent/src/kill_audit.rs +67 -0
  21. package/crates/team-agent/src/leader/lease.rs +324 -57
  22. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  24. package/crates/team-agent/src/leader/start.rs +144 -23
  25. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  26. package/crates/team-agent/src/leader/types.rs +6 -0
  27. package/crates/team-agent/src/lib.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  30. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  31. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  32. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  33. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  34. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  35. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  36. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  37. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  42. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  43. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  44. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  46. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  47. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  48. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  51. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  52. package/crates/team-agent/src/mcp_server/tools.rs +89 -4
  53. package/crates/team-agent/src/mcp_server/types.rs +7 -0
  54. package/crates/team-agent/src/mcp_server/wire.rs +65 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  56. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  57. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  58. package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
  59. package/crates/team-agent/src/messaging/mod.rs +2 -1
  60. package/crates/team-agent/src/messaging/persist.rs +68 -2
  61. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  62. package/crates/team-agent/src/messaging/results.rs +130 -3
  63. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  64. package/crates/team-agent/src/messaging/send.rs +54 -2
  65. package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
  66. package/crates/team-agent/src/messaging/types.rs +2 -0
  67. package/crates/team-agent/src/messaging/watchers.rs +1 -0
  68. package/crates/team-agent/src/provider/adapter.rs +54 -13
  69. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  70. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  71. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  72. package/crates/team-agent/src/provider/session/capture.rs +395 -52
  73. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  74. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  75. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  76. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  77. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  78. package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
  79. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  80. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  81. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  82. package/crates/team-agent/src/provider/tests.rs +1 -0
  83. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  84. package/crates/team-agent/src/tmux_backend.rs +29 -2
  85. package/package.json +4 -4
  86. package/schemas/result-envelope.schema.json +10 -0
  87. package/skills/team-agent/SKILL.md +11 -3
@@ -22,6 +22,8 @@
22
22
  //! per-file layout.
23
23
 
24
24
  pub(crate) mod claude;
25
+ pub(crate) mod claude_fork;
25
26
  pub(crate) mod codex;
26
27
  pub(crate) mod copilot;
28
+ pub(crate) mod copilot_fork;
27
29
  pub(crate) mod fake;
@@ -335,7 +335,7 @@ where
335
335
  // * `transcript_missing` — trigger event has fired (report_result /
336
336
  // first_send_at / pane_output_advanced) AND elapsed > threshold
337
337
  // AND zero candidates / no backing
338
- // * `waiting_for_transcript` — still in grace period
338
+ // * `pending_first_turn` — no verified backing yet, still in grace
339
339
  // The state field surfaces in status/diagnose without changing the
340
340
  // authoritative tuple contract (we never write session_id without
341
341
  // backing). Throttled event emission is done by the caller (tick).
@@ -415,11 +415,11 @@ where
415
415
  /// 0.4.6 Stage 3: classify a pending agent's capture state into one of:
416
416
  /// * `transcript_missing` — has had a strong trigger event AND elapsed
417
417
  /// beyond grace threshold AND still no backing
418
- /// * `waiting_for_transcript` — still in grace period or no trigger yet
418
+ /// * `pending_first_turn` — still in grace period or no trigger yet
419
419
  ///
420
420
  /// Trigger events (CR M2): any of `first_send_at`, `last_result_at`, or a
421
421
  /// recent `last_pane_output_at` indicates the worker has been interactively
422
- /// used / done something. Without ANY trigger, we stay in waiting state
422
+ /// used / done something. Without ANY trigger, we stay pending
423
423
  /// even past the grace window (silent worker is not a failure to capture).
424
424
  ///
425
425
  /// Threshold: 30 seconds from spawned_at OR 15 seconds from the first
@@ -477,7 +477,7 @@ fn classify_pending_capture_state(
477
477
  if has_trigger && past_spawn_grace && past_trigger_grace && candidate_count == 0 {
478
478
  "transcript_missing"
479
479
  } else {
480
- "waiting_for_transcript"
480
+ "pending_first_turn"
481
481
  }
482
482
  }
483
483
 
@@ -488,8 +488,7 @@ pub fn incomplete_resumable_agent_ids(state: &Value) -> Vec<String> {
488
488
  let mut out = agents
489
489
  .iter()
490
490
  .filter_map(|(agent_id, agent)| {
491
- if pending_session_capture(agent_id, agent, &mut crate::provider::get_adapter).is_some()
492
- {
491
+ if incomplete_resumable_provider(agent, &mut crate::provider::get_adapter).is_some() {
493
492
  Some(agent_id.clone())
494
493
  } else {
495
494
  None
@@ -651,27 +650,20 @@ fn pending_session_capture<F>(
651
650
  where
652
651
  F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
653
652
  {
654
- if agent
655
- .get("status")
656
- .and_then(Value::as_str)
657
- .is_some_and(|status| status != "running")
658
- {
659
- return None;
660
- }
661
- if agent_session_complete(agent) {
662
- return None;
663
- }
664
- let provider = agent
665
- .get("provider")
666
- .and_then(Value::as_str)
667
- .and_then(parse_provider)?;
653
+ let provider = incomplete_resumable_provider(agent, adapter_for)?;
668
654
  let spawn_cwd = agent
669
655
  .get("spawn_cwd")
670
656
  .and_then(Value::as_str)
671
657
  .filter(|cwd| !cwd.is_empty())?;
672
- if !adapter_for(provider).caps().resume {
673
- return None;
674
- }
658
+ // New cohorts carry a boundary and are filtered fail-closed by the
659
+ // canonical scanner. Legacy rows may predate spawned_at; keep their
660
+ // boundary absent so they can still use the allocator's identity,
661
+ // collision, and unique-candidate guards instead of being skipped.
662
+ let spawned_at = agent
663
+ .get("spawned_at")
664
+ .and_then(Value::as_str)
665
+ .filter(|value| !value.is_empty())
666
+ .map(str::to_string);
675
667
  Some(PendingSessionCapture {
676
668
  agent_id: agent_id.to_string(),
677
669
  provider,
@@ -694,25 +686,7 @@ where
694
686
  .get("pane_pid")
695
687
  .and_then(Value::as_u64)
696
688
  .and_then(|pid| u32::try_from(pid).ok()),
697
- spawned_at: agent
698
- .get("spawned_at")
699
- .and_then(Value::as_str)
700
- .filter(|value| !value.is_empty())
701
- .map(str::to_string),
702
- // 0.3.31 Codex capture correction: Codex does NOT honor
703
- // `--session-id`, so any `_pending_session_id` stored for a Codex
704
- // agent (stale 0.3.30 state, or the framework's pre-spawn token)
705
- // is a local-only token and must NOT be used as expected_session_id
706
- // — that would trigger the Stage 1 mismatch guard against Codex's
707
- // real session_meta.payload.id and permanently reject the correct
708
- // transcript. Codex capture anchors purely on (cwd, spawned_at).
709
- // 0.4.7 (B1 verified, partial-resume revert of 9feafc31):
710
- // Claude --session-id was restored in adapter.rs build_command_plan
711
- // because Claude ≥ 2.1.185 DOES honour framework-supplied session
712
- // id and DOES create a transcript at that id. So `_pending_session_id`
713
- // is once again valid for Claude — re-enable the Stage 1 pre-pass
714
- // for Claude/ClaudeCode. Codex remains excluded (Codex CLI ignores
715
- // --session-id, capture must anchor purely on cwd+spawned_at).
689
+ spawned_at,
716
690
  expected_session_id: if matches!(provider, Provider::Codex) {
717
691
  None
718
692
  } else {
@@ -731,6 +705,34 @@ where
731
705
  })
732
706
  }
733
707
 
708
+ fn incomplete_resumable_provider<F>(agent: &Value, adapter_for: &mut F) -> Option<Provider>
709
+ where
710
+ F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
711
+ {
712
+ if agent
713
+ .get("status")
714
+ .and_then(Value::as_str)
715
+ .is_some_and(|status| status != "running")
716
+ {
717
+ return None;
718
+ }
719
+ if agent_session_complete(agent) {
720
+ return None;
721
+ }
722
+ let provider = agent
723
+ .get("provider")
724
+ .and_then(Value::as_str)
725
+ .and_then(parse_provider)?;
726
+ agent
727
+ .get("spawn_cwd")
728
+ .and_then(Value::as_str)
729
+ .filter(|cwd| !cwd.is_empty())?;
730
+ if !adapter_for(provider).caps().resume {
731
+ return None;
732
+ }
733
+ Some(provider)
734
+ }
735
+
734
736
  fn agent_session_complete(agent: &Value) -> bool {
735
737
  // RM-039-SESS-001 step 1 (architect verdict 2026-06-22): historically
736
738
  // this returned true the moment `rollout_path` was non-empty — even when
@@ -966,6 +968,17 @@ fn allocate_session_candidates(
966
968
  CandidateMatchKind::Any,
967
969
  item,
968
970
  ) {
971
+ Some(candidate)
972
+ if codex_no_expected(item)
973
+ && candidate_is_visible_to_pending_peer(
974
+ item,
975
+ &candidate,
976
+ pending,
977
+ candidates_by_agent,
978
+ ) =>
979
+ {
980
+ ambiguous.insert(item.agent_id.clone());
981
+ }
969
982
  Some(candidate) => {
970
983
  claimed.extend(captured_provider_session_keys(item, &candidate.captured));
971
984
  assignments.insert(item.agent_id.clone(), candidate);
@@ -983,6 +996,30 @@ fn allocate_session_candidates(
983
996
  (assignments, ambiguous)
984
997
  }
985
998
 
999
+ fn candidate_is_visible_to_pending_peer(
1000
+ owner: &PendingSessionCapture,
1001
+ candidate: &CapturedSessionCandidate,
1002
+ pending: &[PendingSessionCapture],
1003
+ candidates_by_agent: &BTreeMap<String, Vec<CapturedSessionCandidate>>,
1004
+ ) -> bool {
1005
+ let owner_keys = captured_provider_session_keys(owner, &candidate.captured);
1006
+ pending.iter().any(|peer| {
1007
+ peer.agent_id != owner.agent_id
1008
+ && peer.provider == owner.provider
1009
+ && peer.team_key == owner.team_key
1010
+ && candidates_by_agent
1011
+ .get(&peer.agent_id)
1012
+ .is_some_and(|candidates| {
1013
+ candidates.iter().any(|peer_candidate| {
1014
+ !owner_keys.is_disjoint(&captured_provider_session_keys(
1015
+ peer,
1016
+ &peer_candidate.captured,
1017
+ ))
1018
+ })
1019
+ })
1020
+ })
1021
+ }
1022
+
986
1023
  fn allocate_global_one_to_one(
987
1024
  pending: &[PendingSessionCapture],
988
1025
  candidates_by_agent: &BTreeMap<String, Vec<CapturedSessionCandidate>>,
@@ -1150,13 +1187,26 @@ fn claimed_provider_session_keys(
1150
1187
  ) -> BTreeSet<String> {
1151
1188
  let mut keys = BTreeSet::new();
1152
1189
  let team_key = crate::state::projection::team_state_key(state);
1153
- // 1. Non-pending worker sessions (existing behaviour).
1190
+ // 1. Every other already-known worker session. The pending row itself is
1191
+ // excluded so an incomplete tuple can still be repaired in place.
1154
1192
  for (agent_id, agent) in agents {
1155
1193
  if pending_ids.contains(agent_id) {
1156
1194
  continue;
1157
1195
  }
1158
1196
  push_provider_session_keys(&mut keys, &team_key, agent_id, agent);
1159
1197
  }
1198
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
1199
+ for (nested_team_key, team_state) in teams {
1200
+ if let Some(nested_agents) = team_state.get("agents").and_then(Value::as_object) {
1201
+ for (agent_id, agent) in nested_agents {
1202
+ if nested_team_key == &team_key && pending_ids.contains(agent_id) {
1203
+ continue;
1204
+ }
1205
+ push_provider_session_keys(&mut keys, nested_team_key, agent_id, agent);
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1160
1210
  // 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
1161
1211
  // own provider transcript must never be attributed to a worker. Scan
1162
1212
  // state.leader_receiver, state.team_owner, and the same fields under
@@ -1207,6 +1257,12 @@ fn push_provider_session_keys(
1207
1257
  keys.insert(global_session_attribution_key_string(session_id));
1208
1258
  }
1209
1259
  }
1260
+ // Copilot stores every session in one shared session-store.db. Session id
1261
+ // is the attribution identity; treating the shared DB path as globally
1262
+ // exclusive makes an exact expected id collide with every sibling.
1263
+ if matches!(provider, Provider::Copilot) {
1264
+ return;
1265
+ }
1210
1266
  for field in ["rollout_path", "transcript_path"] {
1211
1267
  if let Some(path) = value
1212
1268
  .get(field)
@@ -1246,15 +1302,17 @@ fn captured_provider_session_keys(
1246
1302
  }
1247
1303
  keys.insert(global_session_attribution_key_string(session_id.as_str()));
1248
1304
  }
1249
- if let Some(rollout_path) = &captured.rollout_path {
1250
- let path = rollout_path.as_path().to_string_lossy();
1251
- keys.insert(transcript_attribution_key_string(
1252
- owner.provider,
1253
- team_key,
1254
- owner.agent_id.as_str(),
1255
- &path,
1256
- ));
1257
- keys.insert(global_transcript_attribution_key_string(&path));
1305
+ if !matches!(owner.provider, Provider::Copilot) {
1306
+ if let Some(rollout_path) = &captured.rollout_path {
1307
+ let path = rollout_path.as_path().to_string_lossy();
1308
+ keys.insert(transcript_attribution_key_string(
1309
+ owner.provider,
1310
+ team_key,
1311
+ owner.agent_id.as_str(),
1312
+ &path,
1313
+ ));
1314
+ keys.insert(global_transcript_attribution_key_string(&path));
1315
+ }
1258
1316
  }
1259
1317
  keys
1260
1318
  }
@@ -1679,6 +1737,45 @@ mod u1_tests {
1679
1737
  );
1680
1738
  }
1681
1739
 
1740
+ #[test]
1741
+ fn codex_shared_single_candidate_without_identity_is_deferred() {
1742
+ let mut state = serde_json::json!({
1743
+ "agents": {
1744
+ "parent-peer": {
1745
+ "provider": "codex",
1746
+ "status": "running",
1747
+ "spawn_cwd": "/tmp/u1-cwd"
1748
+ },
1749
+ "worker-a": {
1750
+ "provider": "codex",
1751
+ "status": "running",
1752
+ "spawn_cwd": "/tmp/u1-cwd"
1753
+ }
1754
+ }
1755
+ });
1756
+ let candidate = leader_like_candidate(
1757
+ "019f3327-shared",
1758
+ "/Users/alauda/.codex/sessions/2026/07/06/rollout-shared.jsonl",
1759
+ );
1760
+ let mut canned = BTreeMap::new();
1761
+ canned.insert("parent-peer".to_string(), vec![candidate.clone()]);
1762
+ canned.insert("worker-a".to_string(), vec![candidate]);
1763
+ let canned_for_adapter = canned.clone();
1764
+ let mut adapter_for = move |provider| {
1765
+ Box::new(
1766
+ test_support::CaptureCandidatesAdapter::new(provider, None, "")
1767
+ .with_candidates(canned_for_adapter.clone()),
1768
+ ) as Box<dyn ProviderAdapter>
1769
+ };
1770
+
1771
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
1772
+ .expect("capture pass succeeds");
1773
+
1774
+ assert!(report.assigned.is_empty(), "report={report:?}");
1775
+ assert!(state["agents"]["parent-peer"].get("session_id").is_none());
1776
+ assert!(state["agents"]["worker-a"].get("session_id").is_none());
1777
+ }
1778
+
1682
1779
  #[test]
1683
1780
  fn codex_embedded_identity_assigns_each_same_cwd_worker_to_own_rollout() {
1684
1781
  let mut state = serde_json::json!({
@@ -1869,6 +1966,187 @@ mod u1_tests {
1869
1966
  );
1870
1967
  }
1871
1968
 
1969
+ #[test]
1970
+ fn pending_capture_state_stays_pending_until_a_trigger_expires() {
1971
+ let old = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
1972
+ let mut agent = serde_json::Map::new();
1973
+ agent.insert("spawned_at".to_string(), serde_json::json!(old));
1974
+ assert_eq!(
1975
+ super::classify_pending_capture_state(&agent, 0),
1976
+ "pending_first_turn"
1977
+ );
1978
+
1979
+ let trigger = (chrono::Utc::now() - chrono::Duration::seconds(20)).to_rfc3339();
1980
+ agent.insert("first_send_at".to_string(), serde_json::json!(trigger));
1981
+ assert_eq!(
1982
+ super::classify_pending_capture_state(&agent, 0),
1983
+ "transcript_missing"
1984
+ );
1985
+ }
1986
+
1987
+ #[test]
1988
+ fn canonical_pending_capture_preserves_legacy_missing_boundary() {
1989
+ let agent = serde_json::json!({
1990
+ "provider": "codex",
1991
+ "status": "running",
1992
+ "spawn_cwd": "/tmp/u1-cwd",
1993
+ "capture_state": "pending_first_turn"
1994
+ });
1995
+ let mut adapter_for = crate::provider::get_adapter;
1996
+ let pending = pending_session_capture("clone", &agent, &mut adapter_for)
1997
+ .expect("legacy row still participates in canonical capture");
1998
+ assert_eq!(pending.context.spawned_at, None);
1999
+ }
2000
+
2001
+ #[test]
2002
+ fn canonical_delayed_capture_rejects_prior_round_codex_rollout() {
2003
+ let dir =
2004
+ std::env::temp_dir().join(format!("ta-delayed-stale-codex-{}", std::process::id()));
2005
+ let _ = std::fs::remove_dir_all(&dir);
2006
+ std::fs::create_dir_all(&dir).unwrap();
2007
+ let stale =
2008
+ dir.join("rollout-2026-07-21T23-18-28-019f8541-c986-7f50-b9e6-6503c89df98c.jsonl");
2009
+ std::fs::write(
2010
+ &stale,
2011
+ format!(
2012
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"019f8541-c986-7f50-b9e6-6503c89df98c\",\"cwd\":\"{}\",\"created_at\":\"2026-07-21T23:18:28+00:00\"}}}}}}\n",
2013
+ dir.to_string_lossy()
2014
+ ),
2015
+ )
2016
+ .unwrap();
2017
+ let mut state = serde_json::json!({
2018
+ "agents": {
2019
+ "clone": {
2020
+ "provider": "codex",
2021
+ "status": "running",
2022
+ "spawn_cwd": dir.to_string_lossy(),
2023
+ "spawned_at": "2026-07-22T02:12:27+00:00",
2024
+ "capture_state": "pending_first_turn"
2025
+ }
2026
+ }
2027
+ });
2028
+ let mut adapter_for = crate::provider::get_adapter;
2029
+
2030
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
2031
+ .expect("delayed capture pass");
2032
+
2033
+ assert!(report.assigned.is_empty());
2034
+ assert!(state["agents"]["clone"]["session_id"].is_null());
2035
+ assert_ne!(
2036
+ state["agents"]["clone"]["capture_state"],
2037
+ serde_json::json!("captured")
2038
+ );
2039
+ let _ = std::fs::remove_dir_all(&dir);
2040
+ }
2041
+
2042
+ #[test]
2043
+ fn canonical_delayed_capture_ignores_active_foreign_cwd_rollouts() {
2044
+ use std::io::Write;
2045
+
2046
+ let root =
2047
+ std::env::temp_dir().join(format!("ta-codex-dirty-domain-{}", std::process::id()));
2048
+ let _ = std::fs::remove_dir_all(&root);
2049
+ let current_cwd = root.join("current");
2050
+ let foreign_cwd = root.join("foreign");
2051
+ std::fs::create_dir_all(&current_cwd).unwrap();
2052
+ std::fs::create_dir_all(&foreign_cwd).unwrap();
2053
+ let stale_current = current_cwd.join(
2054
+ "cache/current/rollout-2026-07-21T23-18-28-019f8541-c986-7f50-b9e6-6503c89df98c.jsonl",
2055
+ );
2056
+ let fresh_foreign = current_cwd.join(
2057
+ "cache/foreign/rollout-2026-07-22T04-01-00-019f8644-7060-7000-8000-000000000001.jsonl",
2058
+ );
2059
+ let fresh_current = current_cwd.join(
2060
+ "cache/current/rollout-2026-07-22T04-01-01-019f8644-7450-7000-8000-000000000002.jsonl",
2061
+ );
2062
+ let write_rollout = |path: &std::path::Path, cwd: &std::path::Path, id: &str| {
2063
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2064
+ std::fs::write(
2065
+ path,
2066
+ format!(
2067
+ "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"cwd\":\"{}\"}}}}\n",
2068
+ cwd.to_string_lossy()
2069
+ ),
2070
+ )
2071
+ .unwrap();
2072
+ };
2073
+ write_rollout(&stale_current, &current_cwd, "stale-current");
2074
+ write_rollout(&fresh_foreign, &foreign_cwd, "fresh-foreign");
2075
+ write_rollout(&fresh_current, &current_cwd, "fresh-current");
2076
+ std::fs::OpenOptions::new()
2077
+ .append(true)
2078
+ .open(&stale_current)
2079
+ .unwrap()
2080
+ .write_all(b"{\"type\":\"event_msg\"}\n")
2081
+ .unwrap();
2082
+ let mut state = serde_json::json!({
2083
+ "agents": {"clone": {
2084
+ "provider": "codex", "status": "running",
2085
+ "spawn_cwd": current_cwd.to_string_lossy(),
2086
+ "spawned_at": "2026-07-21T20:00:00+00:00",
2087
+ "capture_state": "pending_first_turn"
2088
+ }}
2089
+ });
2090
+ let mut adapter_for = crate::provider::get_adapter;
2091
+
2092
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
2093
+ .expect("canonical delayed capture");
2094
+
2095
+ assert_eq!(report.candidate_count_by_agent.get("clone"), Some(&1));
2096
+ assert_eq!(state["agents"]["clone"]["session_id"], "fresh-current");
2097
+ assert_eq!(state["agents"]["clone"]["capture_state"], "captured");
2098
+ let _ = std::fs::remove_dir_all(&root);
2099
+ }
2100
+
2101
+ #[test]
2102
+ fn existing_team_session_is_rejected_while_fresh_candidate_is_accepted() {
2103
+ let mut state = serde_json::json!({
2104
+ "session_name": "current",
2105
+ "agents": {
2106
+ "clone": {
2107
+ "provider": "codex",
2108
+ "status": "running",
2109
+ "spawn_cwd": "/tmp/u1-cwd"
2110
+ }
2111
+ },
2112
+ "teams": {
2113
+ "archived": {
2114
+ "agents": {
2115
+ "old-seat": {
2116
+ "provider": "codex",
2117
+ "session_id": "stale-session",
2118
+ "rollout_path": "/tmp/stale.jsonl"
2119
+ }
2120
+ }
2121
+ }
2122
+ }
2123
+ });
2124
+ let mut canned = BTreeMap::new();
2125
+ canned.insert(
2126
+ "clone".to_string(),
2127
+ vec![
2128
+ worker_owned_candidate("stale-session", "/tmp/stale.jsonl"),
2129
+ worker_owned_candidate("fresh-session", "/tmp/fresh.jsonl"),
2130
+ ],
2131
+ );
2132
+ let canned_for_adapter = canned.clone();
2133
+ let mut adapter_for = move |provider| {
2134
+ Box::new(
2135
+ test_support::CaptureCandidatesAdapter::new(provider, None, "")
2136
+ .with_candidates(canned_for_adapter.clone()),
2137
+ ) as Box<dyn ProviderAdapter>
2138
+ };
2139
+
2140
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
2141
+ .expect("capture pass succeeds");
2142
+
2143
+ assert_eq!(report.assigned, vec!["clone".to_string()]);
2144
+ assert_eq!(
2145
+ state["agents"]["clone"]["session_id"].as_str(),
2146
+ Some("fresh-session")
2147
+ );
2148
+ }
2149
+
1872
2150
  #[test]
1873
2151
  fn capture_pass_keeps_pending_agent_when_one_adapter_capture_fails() {
1874
2152
  let mut state = serde_json::json!({
@@ -2102,6 +2380,71 @@ mod u1_tests {
2102
2380
  let _ = std::fs::remove_dir(&dir);
2103
2381
  }
2104
2382
 
2383
+ #[test]
2384
+ fn copilot_exact_session_id_does_not_collide_on_shared_store_path() {
2385
+ use crate::provider::{CaptureVia, Confidence, RolloutPath};
2386
+ let dir =
2387
+ std::env::temp_dir().join(format!("ta-copilot-shared-store-{}", std::process::id()));
2388
+ let _ = std::fs::remove_dir_all(&dir);
2389
+ std::fs::create_dir_all(&dir).unwrap();
2390
+ let store = dir.join("session-store.db");
2391
+ std::fs::write(&store, b"fixture").unwrap();
2392
+ let expected = "22222222-2222-4222-8222-222222222222";
2393
+ let candidate = CapturedSessionCandidate {
2394
+ captured: CapturedSession {
2395
+ session_id: Some(SessionId::new(expected)),
2396
+ rollout_path: Some(RolloutPath::new(store.clone())),
2397
+ captured_via: CaptureVia::FsWatch,
2398
+ attribution_confidence: Confidence::High,
2399
+ spawn_cwd: dir.clone(),
2400
+ },
2401
+ embedded_agent_id: None,
2402
+ positive_agent_id_match: false,
2403
+ agent_path_match: false,
2404
+ };
2405
+ let mut by_agent = BTreeMap::new();
2406
+ by_agent.insert("clone".to_string(), vec![candidate]);
2407
+ let mut state = serde_json::json!({
2408
+ "agents": {
2409
+ "source": {
2410
+ "provider": "copilot",
2411
+ "status": "running",
2412
+ "spawn_cwd": dir.to_string_lossy(),
2413
+ "session_id": "11111111-1111-4111-8111-111111111111",
2414
+ "rollout_path": store.to_string_lossy(),
2415
+ "captured_at": "2026-07-22T01:00:00+00:00",
2416
+ "captured_via": "fs_watch"
2417
+ },
2418
+ "clone": {
2419
+ "provider": "copilot",
2420
+ "status": "running",
2421
+ "spawn_cwd": dir.to_string_lossy(),
2422
+ "spawned_at": "2026-07-22T02:00:00+00:00",
2423
+ "_pending_session_id": expected
2424
+ }
2425
+ }
2426
+ });
2427
+ let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Copilot, None, "")
2428
+ .with_candidates(by_agent);
2429
+ let mut adapter_for =
2430
+ move |_provider| Box::new(adapter.clone()) as Box<dyn ProviderAdapter>;
2431
+
2432
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
2433
+ .expect("capture shared Copilot store");
2434
+
2435
+ assert_eq!(report.assigned, vec!["clone".to_string()]);
2436
+ assert_eq!(state["agents"]["clone"]["session_id"], expected);
2437
+ assert_eq!(
2438
+ state["agents"]["clone"]["rollout_path"],
2439
+ store.to_string_lossy().as_ref()
2440
+ );
2441
+ assert_eq!(
2442
+ state["agents"]["source"]["session_id"],
2443
+ "11111111-1111-4111-8111-111111111111"
2444
+ );
2445
+ let _ = std::fs::remove_dir_all(&dir);
2446
+ }
2447
+
2105
2448
  /// E57 RED postflight (lane-046-capture-gap): `recover_resume_session_from_events`
2106
2449
  /// must refuse to repair a worker's session from a stale `session.captured`
2107
2450
  /// event whose rollout file is a Claude LEADER transcript