@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +18 -2
- package/crates/team-agent/src/cli/emit.rs +17 -0
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/named_address.rs +35 -67
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +11 -0
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +526 -73
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
- package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
- package/crates/team-agent/src/lifecycle/restart.rs +25 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/mcp_server/tools.rs +18 -4
- package/crates/team-agent/src/mcp_server/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/wire.rs +30 -7
- package/crates/team-agent/src/messaging/delivery.rs +126 -147
- package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
- package/crates/team-agent/src/messaging/mod.rs +5 -0
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +321 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +282 -20
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository.rs +36 -12
- package/crates/team-agent/src/state/selector.rs +9 -18
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +8 -3
|
@@ -1,37 +1,47 @@
|
|
|
1
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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 =
|
|
14
|
-
|
|
15
|
-
None => return false,
|
|
18
|
+
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
19
|
+
return false;
|
|
16
20
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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(|
|
|
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::
|
|
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
|
|
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("
|
|
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::
|
|
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(¢ral, "sessions", "id", new_id), 1);
|
|
56
|
+
assert_eq!(
|
|
57
|
+
session_count(¢ral, "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(¢ral, table, "session_id", new_id), 1);
|
|
69
|
+
assert_eq!(
|
|
70
|
+
session_count(¢ral, 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
|
+
}
|