@team-agent/installer 0.5.51 → 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 (79) 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/named_address.rs +35 -67
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +11 -0
  10. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  11. package/crates/team-agent/src/kill_audit.rs +67 -0
  12. package/crates/team-agent/src/leader/lease.rs +526 -73
  13. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  14. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  15. package/crates/team-agent/src/leader/start.rs +144 -23
  16. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  17. package/crates/team-agent/src/leader/types.rs +6 -0
  18. package/crates/team-agent/src/lib.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  20. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
  21. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  24. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  26. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  29. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  30. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  32. package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
  33. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  34. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
  35. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  36. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
  38. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  41. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  42. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  43. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  44. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  45. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  46. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  47. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  48. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  49. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  50. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  51. package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
  52. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  53. package/crates/team-agent/src/messaging/mod.rs +5 -0
  54. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  55. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  56. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  57. package/crates/team-agent/src/provider/adapter.rs +54 -13
  58. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  59. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  60. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  61. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  62. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  63. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  64. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  65. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  66. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  67. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  68. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  69. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  70. package/crates/team-agent/src/provider/tests.rs +1 -0
  71. package/crates/team-agent/src/state/persist.rs +282 -20
  72. package/crates/team-agent/src/state/projection.rs +245 -30
  73. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  74. package/crates/team-agent/src/state/repository.rs +36 -12
  75. package/crates/team-agent/src/state/selector.rs +9 -18
  76. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  77. package/crates/team-agent/src/tmux_backend.rs +29 -2
  78. package/package.json +4 -4
  79. package/skills/team-agent/SKILL.md +8 -3
@@ -1,37 +1,47 @@
1
- use super::{CaptureSessionContext, CapturedSessionCandidate};
1
+ pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
2
+ chrono::DateTime::parse_from_rfc3339(raw)
3
+ .ok()
4
+ .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
5
+ }
2
6
 
3
- pub(super) fn apply_spawned_at_filter(
4
- out: &mut Vec<CapturedSessionCandidate>,
5
- context: &CaptureSessionContext,
7
+ pub(super) fn rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
8
+ created_at_from_rollout_uuid(path)
9
+ .or_else(|| created_at_from_rollout_head(path))
10
+ .or_else(|| created_at_from_rollout_filename(path))
11
+ }
12
+
13
+ pub(super) fn retain_spawn_cwd(
14
+ context: &super::CaptureSessionContext,
15
+ out: &mut Vec<super::CapturedSessionCandidate>,
6
16
  ) {
7
- let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) else {
8
- return;
9
- };
10
- let grace = std::time::Duration::from_secs(5);
11
- let cutoff = spawned_at.checked_sub(grace).unwrap_or(spawned_at);
12
17
  out.retain(|candidate| {
13
- let path = match candidate.captured.rollout_path.as_ref() {
14
- Some(p) => p.as_path(),
15
- None => return false,
18
+ let Some(path) = candidate.captured.rollout_path.as_ref() else {
19
+ return false;
16
20
  };
17
- match codex_rollout_created_at(path) {
18
- Some(created_at) => created_at >= cutoff,
19
- None => std::fs::metadata(path)
20
- .and_then(|meta| meta.modified())
21
- .map(|mtime| mtime >= cutoff)
22
- .unwrap_or(false),
23
- }
21
+ let Ok(text) =
22
+ super::common::read_head_text(path.as_path(), super::common::CAPTURE_HEAD_BYTES)
23
+ else {
24
+ return false;
25
+ };
26
+ super::common::parse_session_records(&text)
27
+ .iter()
28
+ .filter_map(super::common::record_cwd)
29
+ .any(|cwd| {
30
+ super::common::paths_equivalent(std::path::Path::new(&cwd), &context.spawn_cwd)
31
+ })
24
32
  });
25
33
  }
26
34
 
27
- pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
28
- chrono::DateTime::parse_from_rfc3339(raw)
29
- .ok()
30
- .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
31
- }
32
-
33
- fn codex_rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
34
- created_at_from_rollout_head(path).or_else(|| created_at_from_rollout_filename(path))
35
+ fn created_at_from_rollout_uuid(path: &std::path::Path) -> Option<std::time::SystemTime> {
36
+ let name = path.file_name()?.to_str()?;
37
+ let stem = name.strip_suffix(".jsonl")?;
38
+ let uuid = stem.get(stem.len().checked_sub(36)?..)?;
39
+ if uuid.as_bytes().get(14).copied() != Some(b'7') {
40
+ return None;
41
+ }
42
+ let millis =
43
+ u64::from_str_radix(&format!("{}{}", uuid.get(..8)?, uuid.get(9..13)?), 16).ok()?;
44
+ std::time::SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(millis))
35
45
  }
36
46
 
37
47
  fn created_at_from_rollout_head(path: &std::path::Path) -> Option<std::time::SystemTime> {
@@ -77,6 +87,10 @@ fn created_at_from_rollout_filename(path: &std::path::Path) -> Option<std::time:
77
87
  #[cfg(test)]
78
88
  mod tests {
79
89
  use super::*;
90
+ use crate::provider::session_scan::{common, CaptureSessionContext, CapturedSessionCandidate};
91
+ use crate::provider::Provider;
92
+ use crate::provider::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
93
+ use std::path::PathBuf;
80
94
 
81
95
  #[test]
82
96
  fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
@@ -84,4 +98,107 @@ mod tests {
84
98
  assert!(parse_spawned_at("not-a-date").is_none());
85
99
  assert!(parse_spawned_at("").is_none());
86
100
  }
101
+
102
+ fn candidate(path: PathBuf, session_id: &str) -> CapturedSessionCandidate {
103
+ CapturedSessionCandidate {
104
+ captured: CapturedSession {
105
+ session_id: Some(SessionId::new(session_id)),
106
+ rollout_path: Some(RolloutPath::new(path)),
107
+ captured_via: CaptureVia::FsWatch,
108
+ attribution_confidence: Confidence::High,
109
+ spawn_cwd: PathBuf::from("/tmp"),
110
+ },
111
+ embedded_agent_id: None,
112
+ positive_agent_id_match: false,
113
+ agent_path_match: false,
114
+ }
115
+ }
116
+
117
+ #[test]
118
+ fn spawned_at_filter_rejects_prior_round_and_accepts_current_rollout() {
119
+ let dir =
120
+ std::env::temp_dir().join(format!("ta-codex-spawn-filter-{}", std::process::id()));
121
+ let _ = std::fs::remove_dir_all(&dir);
122
+ std::fs::create_dir_all(&dir).unwrap();
123
+ let stale =
124
+ dir.join("rollout-2026-07-21T22-45-40-11111111-1111-4111-8111-111111111111.jsonl");
125
+ let fresh =
126
+ dir.join("rollout-2026-07-21T23-20-01-22222222-2222-4222-8222-222222222222.jsonl");
127
+ std::fs::write(&stale, "{}\n").unwrap();
128
+ std::fs::write(&fresh, "{}\n").unwrap();
129
+ let context = CaptureSessionContext {
130
+ agent_id: "worker".to_string(),
131
+ spawn_cwd: dir.clone(),
132
+ pane_id: None,
133
+ pane_pid: None,
134
+ spawned_at: Some("2026-07-21T23:20:00+00:00".to_string()),
135
+ expected_session_id: None,
136
+ provider_projects_root: None,
137
+ };
138
+ let mut out = vec![
139
+ candidate(stale, "11111111-1111-4111-8111-111111111111"),
140
+ candidate(fresh.clone(), "22222222-2222-4222-8222-222222222222"),
141
+ ];
142
+ common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
143
+ assert_eq!(out.len(), 1);
144
+ assert_eq!(
145
+ out[0].captured.rollout_path.as_ref().unwrap().as_path(),
146
+ fresh
147
+ );
148
+ let _ = std::fs::remove_dir_all(&dir);
149
+ }
150
+
151
+ #[test]
152
+ fn spawned_at_filter_fails_closed_with_an_invalid_boundary() {
153
+ let mut out = vec![candidate(PathBuf::from("/tmp/old.jsonl"), "old")];
154
+ let mut context = CaptureSessionContext {
155
+ agent_id: "worker".to_string(),
156
+ spawn_cwd: PathBuf::from("/tmp"),
157
+ pane_id: None,
158
+ pane_pid: None,
159
+ spawned_at: None,
160
+ expected_session_id: None,
161
+ provider_projects_root: None,
162
+ };
163
+ common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
164
+ assert_eq!(out.len(), 1, "legacy direct scan has no cohort boundary");
165
+
166
+ context.spawned_at = Some("not-a-date".to_string());
167
+ common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
168
+ assert!(out.is_empty());
169
+ }
170
+
171
+ #[test]
172
+ fn unique_window_uses_created_at_not_active_sibling_mtime() {
173
+ let dir =
174
+ std::env::temp_dir().join(format!("ta-codex-created-window-{}", std::process::id()));
175
+ let _ = std::fs::remove_dir_all(&dir);
176
+ std::fs::create_dir_all(&dir).unwrap();
177
+ let stale =
178
+ dir.join("rollout-2026-07-22T03-12-13-11111111-1111-4111-8111-111111111111.jsonl");
179
+ let fresh =
180
+ dir.join("rollout-2026-07-22T03-15-30-22222222-2222-4222-8222-222222222222.jsonl");
181
+ std::fs::write(&stale, "{}\n").unwrap();
182
+ std::fs::write(&fresh, "{}\n").unwrap();
183
+ let context = CaptureSessionContext {
184
+ agent_id: "clone".to_string(),
185
+ spawn_cwd: dir.clone(),
186
+ pane_id: None,
187
+ pane_pid: None,
188
+ spawned_at: Some("2026-07-22T03:15:29+00:00".to_string()),
189
+ expected_session_id: None,
190
+ provider_projects_root: None,
191
+ };
192
+ let mut out = vec![
193
+ candidate(stale, "11111111-1111-4111-8111-111111111111"),
194
+ candidate(fresh.clone(), "22222222-2222-4222-8222-222222222222"),
195
+ ];
196
+ common::apply_spawn_time_window_if_unique(Provider::Codex, &context, &mut out);
197
+ assert_eq!(out.len(), 1);
198
+ assert_eq!(
199
+ out[0].captured.rollout_path.as_ref().unwrap().as_path(),
200
+ fresh
201
+ );
202
+ let _ = std::fs::remove_dir_all(&dir);
203
+ }
87
204
  }
@@ -129,7 +129,41 @@ pub(super) fn parse_candidate_files(
129
129
  out
130
130
  }
131
131
 
132
+ /// Reject provider files older than the persisted process-cohort boundary.
133
+ pub(super) fn apply_spawned_at_filter(
134
+ provider: Provider,
135
+ context: &CaptureSessionContext,
136
+ out: &mut Vec<CapturedSessionCandidate>,
137
+ ) {
138
+ let Some(raw_spawned_at) = context.spawned_at.as_deref() else {
139
+ // Legacy direct capture_session_id callers have no cohort boundary;
140
+ // the canonical runtime capture path rejects such rows before scan.
141
+ return;
142
+ };
143
+ let Some(spawned_at) = super::codex::parse_spawned_at(raw_spawned_at) else {
144
+ out.clear();
145
+ return;
146
+ };
147
+ out.retain(|candidate| {
148
+ let Some(path) = candidate.captured.rollout_path.as_ref() else {
149
+ return false;
150
+ };
151
+ if matches!(provider, Provider::Codex) {
152
+ return super::codex::rollout_created_at(path.as_path())
153
+ .is_some_and(|created_at| created_at >= spawned_at);
154
+ }
155
+ candidate_mtime(path.as_path()).is_some_and(|mtime| mtime >= spawned_at)
156
+ });
157
+ }
158
+
159
+ fn candidate_mtime(path: &Path) -> Option<std::time::SystemTime> {
160
+ std::fs::metadata(path)
161
+ .and_then(|meta| meta.modified())
162
+ .ok()
163
+ }
164
+
132
165
  pub(super) fn apply_spawn_time_window_if_unique(
166
+ provider: Provider,
133
167
  context: &CaptureSessionContext,
134
168
  out: &mut Vec<CapturedSessionCandidate>,
135
169
  ) {
@@ -150,12 +184,11 @@ pub(super) fn apply_spawn_time_window_if_unique(
150
184
  .captured
151
185
  .rollout_path
152
186
  .as_ref()
153
- .and_then(|p| {
154
- std::fs::metadata(p.as_path())
155
- .and_then(|m| m.modified())
156
- .ok()
187
+ .and_then(|p| match provider {
188
+ Provider::Codex => super::codex::rollout_created_at(p.as_path()),
189
+ _ => candidate_mtime(p.as_path()),
157
190
  })
158
- .is_some_and(|mtime| mtime >= spawned_at)
191
+ .is_some_and(|created_at| created_at >= spawned_at)
159
192
  })
160
193
  .cloned()
161
194
  .collect();
@@ -366,7 +399,7 @@ pub(super) fn record_cwd(record: &serde_json::Value) -> Option<String> {
366
399
  .map(ToString::to_string)
367
400
  }
368
401
 
369
- fn paths_equivalent(left: &Path, right: &Path) -> bool {
402
+ pub(super) fn paths_equivalent(left: &Path, right: &Path) -> bool {
370
403
  if left == right {
371
404
  return true;
372
405
  }
@@ -1,14 +1,14 @@
1
- use std::path::{Path, PathBuf};
1
+ use std::path::Path;
2
2
 
3
3
  use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
4
4
 
5
5
  use super::{CaptureSessionContext, CapturedSessionCandidate};
6
6
 
7
7
  pub(super) fn scan_session_store(context: &CaptureSessionContext) -> Vec<CapturedSessionCandidate> {
8
- let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
8
+ let Ok(home) = crate::provider::adapters::copilot_fork::copilot_home() else {
9
9
  return Vec::new();
10
10
  };
11
- let db_path = home.join(".copilot").join("session-store.db");
11
+ let db_path = home.join("session-store.db");
12
12
  if !db_path.exists() {
13
13
  return Vec::new();
14
14
  }
@@ -59,6 +59,7 @@ mod tests {
59
59
  #![allow(clippy::unwrap_used)]
60
60
 
61
61
  use super::*;
62
+ use std::path::PathBuf;
62
63
  use std::sync::atomic::{AtomicU64, Ordering};
63
64
 
64
65
  struct HomeGuard {
@@ -175,6 +176,49 @@ mod tests {
175
176
  let _ = std::fs::remove_dir_all(&base);
176
177
  }
177
178
 
179
+ #[test]
180
+ #[serial_test::serial(env)]
181
+ fn copilot_scanner_respects_copilot_home() {
182
+ let base = tmp_root("custom-home");
183
+ let copilot_home = base.join("isolated-copilot");
184
+ std::fs::create_dir_all(&copilot_home).unwrap();
185
+ let worker_id = "1142c4c2-0000-4000-8000-000000000001";
186
+ let conn = rusqlite::Connection::open(copilot_home.join("session-store.db")).unwrap();
187
+ conn.execute(
188
+ "create table sessions (id text primary key, cwd text, updated_at integer)",
189
+ [],
190
+ )
191
+ .unwrap();
192
+ conn.execute(
193
+ "insert into sessions (id, cwd, updated_at) values (?1, '/tmp/ws', 1)",
194
+ [worker_id],
195
+ )
196
+ .unwrap();
197
+ drop(conn);
198
+ let previous = std::env::var_os("COPILOT_HOME");
199
+ std::env::set_var("COPILOT_HOME", &copilot_home);
200
+ let ctx = CaptureSessionContext {
201
+ agent_id: "worker".to_string(),
202
+ spawn_cwd: base.join("ws"),
203
+ pane_id: None,
204
+ pane_pid: None,
205
+ spawned_at: None,
206
+ expected_session_id: Some(SessionId::new(worker_id)),
207
+ provider_projects_root: None,
208
+ };
209
+ let out = scan_session_store(&ctx);
210
+ assert_eq!(out.len(), 1);
211
+ assert_eq!(
212
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
213
+ worker_id
214
+ );
215
+ match previous {
216
+ Some(value) => std::env::set_var("COPILOT_HOME", value),
217
+ None => std::env::remove_var("COPILOT_HOME"),
218
+ }
219
+ let _ = std::fs::remove_dir_all(&base);
220
+ }
221
+
178
222
  #[test]
179
223
  #[serial_test::serial(env)]
180
224
  fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
@@ -64,16 +64,16 @@ pub(crate) fn scan_session_candidates_once(
64
64
 
65
65
  let candidates = common::candidate_session_files(provider, context)?;
66
66
  let mut out = common::parse_candidate_files(provider, context, candidates);
67
-
68
67
  if matches!(provider, Provider::Codex) {
69
- codex::apply_spawned_at_filter(&mut out, context);
68
+ codex::retain_spawn_cwd(context, &mut out);
70
69
  }
70
+ common::apply_spawned_at_filter(provider, context, &mut out);
71
71
 
72
72
  if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
73
73
  return claude::apply_expected_session_filter(context, out);
74
74
  }
75
75
 
76
- common::apply_spawn_time_window_if_unique(context, &mut out);
76
+ common::apply_spawn_time_window_if_unique(provider, context, &mut out);
77
77
  common::sort_expected_first_if_needed(context, &mut out);
78
78
  Ok(out)
79
79
  }
@@ -0,0 +1,191 @@
1
+ use std::path::Path;
2
+
3
+ use rusqlite::Connection;
4
+
5
+ #[test]
6
+ #[serial_test::serial(env)]
7
+ fn copilot_fork_copies_isolated_home_and_rekeys_every_session_table() {
8
+ use crate::provider::adapters::copilot_fork::materialize_copilot_fork;
9
+ use std::sync::atomic::{AtomicU64, Ordering};
10
+
11
+ static COUNTER: AtomicU64 = AtomicU64::new(0);
12
+ let root = std::env::temp_dir().join(format!(
13
+ "ta-copilot-fork-{}-{}",
14
+ std::process::id(),
15
+ COUNTER.fetch_add(1, Ordering::Relaxed)
16
+ ));
17
+ let source_home = root.join("source-home");
18
+ let workspace = root.join("workspace");
19
+ std::fs::create_dir_all(&workspace).unwrap();
20
+ let source = SessionId::new("11111111-2222-4333-8444-555555555555");
21
+ seed_copilot_fork_fixture(&source_home, source.as_str(), None);
22
+ let source_events = std::fs::read(
23
+ source_home
24
+ .join("session-state")
25
+ .join(source.as_str())
26
+ .join("events.jsonl"),
27
+ )
28
+ .unwrap();
29
+ let previous = std::env::var_os("COPILOT_HOME");
30
+ std::env::set_var("COPILOT_HOME", &source_home);
31
+
32
+ let materialized = materialize_copilot_fork(&workspace, "fork-a", &source).unwrap();
33
+ assert_ne!(materialized.session_id(), &source);
34
+ assert!(materialized
35
+ .home()
36
+ .starts_with(workspace.join(".team/runtime/provider-session-forks/copilot/fork-a")));
37
+ let new_id = materialized.session_id().as_str();
38
+ let target_state = materialized.home().join("session-state").join(new_id);
39
+ assert!(target_state.is_dir());
40
+ assert_eq!(
41
+ std::fs::read_to_string(target_state.join("events.jsonl")).unwrap(),
42
+ format!("{{\"session_id\":\"{new_id}\"}}\n")
43
+ );
44
+ let per_session = Connection::open(target_state.join("session.db")).unwrap();
45
+ let recipient: String = per_session
46
+ .query_row(
47
+ "select recipient_session_id from inbox_entries",
48
+ [],
49
+ |row| row.get(0),
50
+ )
51
+ .unwrap();
52
+ assert_eq!(recipient, new_id);
53
+
54
+ let central = Connection::open(materialized.home().join("session-store.db")).unwrap();
55
+ assert_eq!(session_count(&central, "sessions", "id", new_id), 1);
56
+ assert_eq!(
57
+ session_count(&central, "sessions", "id", source.as_str()),
58
+ 0
59
+ );
60
+ for table in [
61
+ "turns",
62
+ "checkpoints",
63
+ "session_files",
64
+ "session_refs",
65
+ "forge_trajectory_events",
66
+ "search_index",
67
+ ] {
68
+ assert_eq!(session_count(&central, table, "session_id", new_id), 1);
69
+ assert_eq!(
70
+ session_count(&central, table, "session_id", source.as_str()),
71
+ 0
72
+ );
73
+ }
74
+ assert_eq!(
75
+ std::fs::read(
76
+ source_home
77
+ .join("session-state")
78
+ .join(source.as_str())
79
+ .join("events.jsonl")
80
+ )
81
+ .unwrap(),
82
+ source_events,
83
+ "fork must not mutate source backing"
84
+ );
85
+
86
+ restore_env("COPILOT_HOME", previous);
87
+ let _ = std::fs::remove_dir_all(root);
88
+ }
89
+
90
+ #[test]
91
+ #[serial_test::serial(env)]
92
+ fn copilot_fork_fails_closed_when_a_required_session_table_is_missing() {
93
+ let root = std::env::temp_dir().join(format!("ta-copilot-fork-missing-{}", std::process::id()));
94
+ let source_home = root.join("source-home");
95
+ let workspace = root.join("workspace");
96
+ std::fs::create_dir_all(&workspace).unwrap();
97
+ let source = SessionId::new("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
98
+ seed_copilot_fork_fixture(&source_home, source.as_str(), Some("session_refs"));
99
+ let previous = std::env::var_os("COPILOT_HOME");
100
+ std::env::set_var("COPILOT_HOME", &source_home);
101
+
102
+ let error = crate::provider::adapters::copilot_fork::materialize_copilot_fork(
103
+ &workspace, "fork-b", &source,
104
+ )
105
+ .unwrap_err();
106
+ assert!(error.to_string().contains("session_refs"), "{error}");
107
+ let managed_parent = workspace.join(".team/runtime/provider-session-forks/copilot/fork-b");
108
+ assert!(
109
+ !managed_parent.exists() || std::fs::read_dir(managed_parent).unwrap().next().is_none(),
110
+ "failed materialization must leave no clone home"
111
+ );
112
+
113
+ restore_env("COPILOT_HOME", previous);
114
+ let _ = std::fs::remove_dir_all(root);
115
+ }
116
+
117
+ fn seed_copilot_fork_fixture(home: &Path, source: &str, omitted: Option<&str>) {
118
+ let state = home.join("session-state").join(source);
119
+ std::fs::create_dir_all(state.join("checkpoints")).unwrap();
120
+ std::fs::write(
121
+ state.join("workspace.yaml"),
122
+ format!("session_id: {source}\n"),
123
+ )
124
+ .unwrap();
125
+ std::fs::write(
126
+ state.join("events.jsonl"),
127
+ format!("{{\"session_id\":\"{source}\"}}\n"),
128
+ )
129
+ .unwrap();
130
+ let session_db = Connection::open(state.join("session.db")).unwrap();
131
+ session_db
132
+ .execute_batch(
133
+ "create table inbox_entries (id text primary key, recipient_session_id text not null);",
134
+ )
135
+ .unwrap();
136
+ session_db
137
+ .execute("insert into inbox_entries values ('one', ?1)", [source])
138
+ .unwrap();
139
+
140
+ let db = Connection::open(home.join("session-store.db")).unwrap();
141
+ db.execute_batch(
142
+ "create table sessions (id text primary key);
143
+ create table turns (id integer primary key, session_id text not null);
144
+ create table checkpoints (id integer primary key, session_id text not null);
145
+ create table session_files (id integer primary key, session_id text not null);
146
+ create table session_refs (id integer primary key, session_id text not null);
147
+ create table forge_trajectory_events (id integer primary key, session_id text not null);
148
+ create virtual table search_index using fts5(content, session_id unindexed);",
149
+ )
150
+ .unwrap();
151
+ db.execute("insert into sessions values (?1)", [source])
152
+ .unwrap();
153
+ for table in [
154
+ "turns",
155
+ "checkpoints",
156
+ "session_files",
157
+ "session_refs",
158
+ "forge_trajectory_events",
159
+ ] {
160
+ if omitted != Some(table) {
161
+ db.execute(
162
+ &format!("insert into {table} (session_id) values (?1)"),
163
+ [source],
164
+ )
165
+ .unwrap();
166
+ } else {
167
+ db.execute(&format!("drop table {table}"), []).unwrap();
168
+ }
169
+ }
170
+ db.execute(
171
+ "insert into search_index (content, session_id) values ('x', ?1)",
172
+ [source],
173
+ )
174
+ .unwrap();
175
+ }
176
+
177
+ fn session_count(conn: &Connection, table: &str, column: &str, id: &str) -> i64 {
178
+ conn.query_row(
179
+ &format!("select count(*) from {table} where {column} = ?1"),
180
+ [id],
181
+ |row| row.get(0),
182
+ )
183
+ .unwrap()
184
+ }
185
+
186
+ fn restore_env(key: &str, value: Option<std::ffi::OsString>) {
187
+ match value {
188
+ Some(value) => std::env::set_var(key, value),
189
+ None => std::env::remove_var(key),
190
+ }
191
+ }
@@ -29,3 +29,4 @@ include!("tests/classify.rs");
29
29
  include!("tests/idle.rs");
30
30
  include!("tests/adapter.rs");
31
31
  include!("tests/faults.rs");
32
+ include!("tests/copilot_fork.rs");