@team-agent/installer 0.5.52 → 0.5.53

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 (62) 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 +17 -0
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/spec.rs +3 -0
  7. package/crates/team-agent/src/cli/types.rs +11 -0
  8. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  9. package/crates/team-agent/src/kill_audit.rs +67 -0
  10. package/crates/team-agent/src/leader/lease.rs +324 -57
  11. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  12. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  13. package/crates/team-agent/src/leader/start.rs +144 -23
  14. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  15. package/crates/team-agent/src/leader/types.rs +6 -0
  16. package/crates/team-agent/src/lib.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  18. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  19. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  20. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  21. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  22. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  24. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  25. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  26. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  27. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  28. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  30. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  31. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  32. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  33. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  34. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  35. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  36. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  37. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  38. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  39. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  40. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  41. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  42. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  44. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  45. package/crates/team-agent/src/provider/adapter.rs +54 -13
  46. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  47. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  48. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  49. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  50. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  51. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  52. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  53. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  54. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  55. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  56. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  57. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  58. package/crates/team-agent/src/provider/tests.rs +1 -0
  59. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  60. package/crates/team-agent/src/tmux_backend.rs +29 -2
  61. package/package.json +4 -4
  62. package/skills/team-agent/SKILL.md +8 -3
@@ -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
@@ -1150,13 +1152,26 @@ fn claimed_provider_session_keys(
1150
1152
  ) -> BTreeSet<String> {
1151
1153
  let mut keys = BTreeSet::new();
1152
1154
  let team_key = crate::state::projection::team_state_key(state);
1153
- // 1. Non-pending worker sessions (existing behaviour).
1155
+ // 1. Every other already-known worker session. The pending row itself is
1156
+ // excluded so an incomplete tuple can still be repaired in place.
1154
1157
  for (agent_id, agent) in agents {
1155
1158
  if pending_ids.contains(agent_id) {
1156
1159
  continue;
1157
1160
  }
1158
1161
  push_provider_session_keys(&mut keys, &team_key, agent_id, agent);
1159
1162
  }
1163
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
1164
+ for (nested_team_key, team_state) in teams {
1165
+ if let Some(nested_agents) = team_state.get("agents").and_then(Value::as_object) {
1166
+ for (agent_id, agent) in nested_agents {
1167
+ if nested_team_key == &team_key && pending_ids.contains(agent_id) {
1168
+ continue;
1169
+ }
1170
+ push_provider_session_keys(&mut keys, nested_team_key, agent_id, agent);
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1160
1175
  // 2. P0 (lane-046-capture-gap): leader anchor sessions. The leader's
1161
1176
  // own provider transcript must never be attributed to a worker. Scan
1162
1177
  // state.leader_receiver, state.team_owner, and the same fields under
@@ -1207,6 +1222,12 @@ fn push_provider_session_keys(
1207
1222
  keys.insert(global_session_attribution_key_string(session_id));
1208
1223
  }
1209
1224
  }
1225
+ // Copilot stores every session in one shared session-store.db. Session id
1226
+ // is the attribution identity; treating the shared DB path as globally
1227
+ // exclusive makes an exact expected id collide with every sibling.
1228
+ if matches!(provider, Provider::Copilot) {
1229
+ return;
1230
+ }
1210
1231
  for field in ["rollout_path", "transcript_path"] {
1211
1232
  if let Some(path) = value
1212
1233
  .get(field)
@@ -1246,15 +1267,17 @@ fn captured_provider_session_keys(
1246
1267
  }
1247
1268
  keys.insert(global_session_attribution_key_string(session_id.as_str()));
1248
1269
  }
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));
1270
+ if !matches!(owner.provider, Provider::Copilot) {
1271
+ if let Some(rollout_path) = &captured.rollout_path {
1272
+ let path = rollout_path.as_path().to_string_lossy();
1273
+ keys.insert(transcript_attribution_key_string(
1274
+ owner.provider,
1275
+ team_key,
1276
+ owner.agent_id.as_str(),
1277
+ &path,
1278
+ ));
1279
+ keys.insert(global_transcript_attribution_key_string(&path));
1280
+ }
1258
1281
  }
1259
1282
  keys
1260
1283
  }
@@ -1869,6 +1892,187 @@ mod u1_tests {
1869
1892
  );
1870
1893
  }
1871
1894
 
1895
+ #[test]
1896
+ fn pending_capture_state_stays_pending_until_a_trigger_expires() {
1897
+ let old = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
1898
+ let mut agent = serde_json::Map::new();
1899
+ agent.insert("spawned_at".to_string(), serde_json::json!(old));
1900
+ assert_eq!(
1901
+ super::classify_pending_capture_state(&agent, 0),
1902
+ "pending_first_turn"
1903
+ );
1904
+
1905
+ let trigger = (chrono::Utc::now() - chrono::Duration::seconds(20)).to_rfc3339();
1906
+ agent.insert("first_send_at".to_string(), serde_json::json!(trigger));
1907
+ assert_eq!(
1908
+ super::classify_pending_capture_state(&agent, 0),
1909
+ "transcript_missing"
1910
+ );
1911
+ }
1912
+
1913
+ #[test]
1914
+ fn canonical_pending_capture_preserves_legacy_missing_boundary() {
1915
+ let agent = serde_json::json!({
1916
+ "provider": "codex",
1917
+ "status": "running",
1918
+ "spawn_cwd": "/tmp/u1-cwd",
1919
+ "capture_state": "pending_first_turn"
1920
+ });
1921
+ let mut adapter_for = crate::provider::get_adapter;
1922
+ let pending = pending_session_capture("clone", &agent, &mut adapter_for)
1923
+ .expect("legacy row still participates in canonical capture");
1924
+ assert_eq!(pending.context.spawned_at, None);
1925
+ }
1926
+
1927
+ #[test]
1928
+ fn canonical_delayed_capture_rejects_prior_round_codex_rollout() {
1929
+ let dir =
1930
+ std::env::temp_dir().join(format!("ta-delayed-stale-codex-{}", std::process::id()));
1931
+ let _ = std::fs::remove_dir_all(&dir);
1932
+ std::fs::create_dir_all(&dir).unwrap();
1933
+ let stale =
1934
+ dir.join("rollout-2026-07-21T23-18-28-019f8541-c986-7f50-b9e6-6503c89df98c.jsonl");
1935
+ std::fs::write(
1936
+ &stale,
1937
+ format!(
1938
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"019f8541-c986-7f50-b9e6-6503c89df98c\",\"cwd\":\"{}\",\"created_at\":\"2026-07-21T23:18:28+00:00\"}}}}}}\n",
1939
+ dir.to_string_lossy()
1940
+ ),
1941
+ )
1942
+ .unwrap();
1943
+ let mut state = serde_json::json!({
1944
+ "agents": {
1945
+ "clone": {
1946
+ "provider": "codex",
1947
+ "status": "running",
1948
+ "spawn_cwd": dir.to_string_lossy(),
1949
+ "spawned_at": "2026-07-22T02:12:27+00:00",
1950
+ "capture_state": "pending_first_turn"
1951
+ }
1952
+ }
1953
+ });
1954
+ let mut adapter_for = crate::provider::get_adapter;
1955
+
1956
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
1957
+ .expect("delayed capture pass");
1958
+
1959
+ assert!(report.assigned.is_empty());
1960
+ assert!(state["agents"]["clone"]["session_id"].is_null());
1961
+ assert_ne!(
1962
+ state["agents"]["clone"]["capture_state"],
1963
+ serde_json::json!("captured")
1964
+ );
1965
+ let _ = std::fs::remove_dir_all(&dir);
1966
+ }
1967
+
1968
+ #[test]
1969
+ fn canonical_delayed_capture_ignores_active_foreign_cwd_rollouts() {
1970
+ use std::io::Write;
1971
+
1972
+ let root =
1973
+ std::env::temp_dir().join(format!("ta-codex-dirty-domain-{}", std::process::id()));
1974
+ let _ = std::fs::remove_dir_all(&root);
1975
+ let current_cwd = root.join("current");
1976
+ let foreign_cwd = root.join("foreign");
1977
+ std::fs::create_dir_all(&current_cwd).unwrap();
1978
+ std::fs::create_dir_all(&foreign_cwd).unwrap();
1979
+ let stale_current = current_cwd.join(
1980
+ "cache/current/rollout-2026-07-21T23-18-28-019f8541-c986-7f50-b9e6-6503c89df98c.jsonl",
1981
+ );
1982
+ let fresh_foreign = current_cwd.join(
1983
+ "cache/foreign/rollout-2026-07-22T04-01-00-019f8644-7060-7000-8000-000000000001.jsonl",
1984
+ );
1985
+ let fresh_current = current_cwd.join(
1986
+ "cache/current/rollout-2026-07-22T04-01-01-019f8644-7450-7000-8000-000000000002.jsonl",
1987
+ );
1988
+ let write_rollout = |path: &std::path::Path, cwd: &std::path::Path, id: &str| {
1989
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1990
+ std::fs::write(
1991
+ path,
1992
+ format!(
1993
+ "{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"cwd\":\"{}\"}}}}\n",
1994
+ cwd.to_string_lossy()
1995
+ ),
1996
+ )
1997
+ .unwrap();
1998
+ };
1999
+ write_rollout(&stale_current, &current_cwd, "stale-current");
2000
+ write_rollout(&fresh_foreign, &foreign_cwd, "fresh-foreign");
2001
+ write_rollout(&fresh_current, &current_cwd, "fresh-current");
2002
+ std::fs::OpenOptions::new()
2003
+ .append(true)
2004
+ .open(&stale_current)
2005
+ .unwrap()
2006
+ .write_all(b"{\"type\":\"event_msg\"}\n")
2007
+ .unwrap();
2008
+ let mut state = serde_json::json!({
2009
+ "agents": {"clone": {
2010
+ "provider": "codex", "status": "running",
2011
+ "spawn_cwd": current_cwd.to_string_lossy(),
2012
+ "spawned_at": "2026-07-21T20:00:00+00:00",
2013
+ "capture_state": "pending_first_turn"
2014
+ }}
2015
+ });
2016
+ let mut adapter_for = crate::provider::get_adapter;
2017
+
2018
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
2019
+ .expect("canonical delayed capture");
2020
+
2021
+ assert_eq!(report.candidate_count_by_agent.get("clone"), Some(&1));
2022
+ assert_eq!(state["agents"]["clone"]["session_id"], "fresh-current");
2023
+ assert_eq!(state["agents"]["clone"]["capture_state"], "captured");
2024
+ let _ = std::fs::remove_dir_all(&root);
2025
+ }
2026
+
2027
+ #[test]
2028
+ fn existing_team_session_is_rejected_while_fresh_candidate_is_accepted() {
2029
+ let mut state = serde_json::json!({
2030
+ "session_name": "current",
2031
+ "agents": {
2032
+ "clone": {
2033
+ "provider": "codex",
2034
+ "status": "running",
2035
+ "spawn_cwd": "/tmp/u1-cwd"
2036
+ }
2037
+ },
2038
+ "teams": {
2039
+ "archived": {
2040
+ "agents": {
2041
+ "old-seat": {
2042
+ "provider": "codex",
2043
+ "session_id": "stale-session",
2044
+ "rollout_path": "/tmp/stale.jsonl"
2045
+ }
2046
+ }
2047
+ }
2048
+ }
2049
+ });
2050
+ let mut canned = BTreeMap::new();
2051
+ canned.insert(
2052
+ "clone".to_string(),
2053
+ vec![
2054
+ worker_owned_candidate("stale-session", "/tmp/stale.jsonl"),
2055
+ worker_owned_candidate("fresh-session", "/tmp/fresh.jsonl"),
2056
+ ],
2057
+ );
2058
+ let canned_for_adapter = canned.clone();
2059
+ let mut adapter_for = move |provider| {
2060
+ Box::new(
2061
+ test_support::CaptureCandidatesAdapter::new(provider, None, "")
2062
+ .with_candidates(canned_for_adapter.clone()),
2063
+ ) as Box<dyn ProviderAdapter>
2064
+ };
2065
+
2066
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
2067
+ .expect("capture pass succeeds");
2068
+
2069
+ assert_eq!(report.assigned, vec!["clone".to_string()]);
2070
+ assert_eq!(
2071
+ state["agents"]["clone"]["session_id"].as_str(),
2072
+ Some("fresh-session")
2073
+ );
2074
+ }
2075
+
1872
2076
  #[test]
1873
2077
  fn capture_pass_keeps_pending_agent_when_one_adapter_capture_fails() {
1874
2078
  let mut state = serde_json::json!({
@@ -2102,6 +2306,71 @@ mod u1_tests {
2102
2306
  let _ = std::fs::remove_dir(&dir);
2103
2307
  }
2104
2308
 
2309
+ #[test]
2310
+ fn copilot_exact_session_id_does_not_collide_on_shared_store_path() {
2311
+ use crate::provider::{CaptureVia, Confidence, RolloutPath};
2312
+ let dir =
2313
+ std::env::temp_dir().join(format!("ta-copilot-shared-store-{}", std::process::id()));
2314
+ let _ = std::fs::remove_dir_all(&dir);
2315
+ std::fs::create_dir_all(&dir).unwrap();
2316
+ let store = dir.join("session-store.db");
2317
+ std::fs::write(&store, b"fixture").unwrap();
2318
+ let expected = "22222222-2222-4222-8222-222222222222";
2319
+ let candidate = CapturedSessionCandidate {
2320
+ captured: CapturedSession {
2321
+ session_id: Some(SessionId::new(expected)),
2322
+ rollout_path: Some(RolloutPath::new(store.clone())),
2323
+ captured_via: CaptureVia::FsWatch,
2324
+ attribution_confidence: Confidence::High,
2325
+ spawn_cwd: dir.clone(),
2326
+ },
2327
+ embedded_agent_id: None,
2328
+ positive_agent_id_match: false,
2329
+ agent_path_match: false,
2330
+ };
2331
+ let mut by_agent = BTreeMap::new();
2332
+ by_agent.insert("clone".to_string(), vec![candidate]);
2333
+ let mut state = serde_json::json!({
2334
+ "agents": {
2335
+ "source": {
2336
+ "provider": "copilot",
2337
+ "status": "running",
2338
+ "spawn_cwd": dir.to_string_lossy(),
2339
+ "session_id": "11111111-1111-4111-8111-111111111111",
2340
+ "rollout_path": store.to_string_lossy(),
2341
+ "captured_at": "2026-07-22T01:00:00+00:00",
2342
+ "captured_via": "fs_watch"
2343
+ },
2344
+ "clone": {
2345
+ "provider": "copilot",
2346
+ "status": "running",
2347
+ "spawn_cwd": dir.to_string_lossy(),
2348
+ "spawned_at": "2026-07-22T02:00:00+00:00",
2349
+ "_pending_session_id": expected
2350
+ }
2351
+ }
2352
+ });
2353
+ let adapter = test_support::CaptureCandidatesAdapter::new(Provider::Copilot, None, "")
2354
+ .with_candidates(by_agent);
2355
+ let mut adapter_for =
2356
+ move |_provider| Box::new(adapter.clone()) as Box<dyn ProviderAdapter>;
2357
+
2358
+ let report = capture_missing_provider_sessions_once(&mut state, &mut adapter_for, true, 0)
2359
+ .expect("capture shared Copilot store");
2360
+
2361
+ assert_eq!(report.assigned, vec!["clone".to_string()]);
2362
+ assert_eq!(state["agents"]["clone"]["session_id"], expected);
2363
+ assert_eq!(
2364
+ state["agents"]["clone"]["rollout_path"],
2365
+ store.to_string_lossy().as_ref()
2366
+ );
2367
+ assert_eq!(
2368
+ state["agents"]["source"]["session_id"],
2369
+ "11111111-1111-4111-8111-111111111111"
2370
+ );
2371
+ let _ = std::fs::remove_dir_all(&dir);
2372
+ }
2373
+
2105
2374
  /// E57 RED postflight (lane-046-capture-gap): `recover_resume_session_from_events`
2106
2375
  /// must refuse to repair a worker's session from a stale `session.captured`
2107
2376
  /// event whose rollout file is a Claude LEADER transcript