@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.
- 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 +111 -3
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +12 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
- package/crates/team-agent/src/db/message_store.rs +31 -2
- package/crates/team-agent/src/db/migration.rs +7 -6
- package/crates/team-agent/src/db/schema.rs +18 -5
- 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 +324 -57
- 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 +5 -0
- 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/role_source.rs +170 -0
- 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 +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -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/tests/phase_golden.rs +1 -0
- 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/normalize.rs +8 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +89 -4
- package/crates/team-agent/src/mcp_server/types.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +65 -4
- package/crates/team-agent/src/messaging/delivery.rs +2 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/persist.rs +68 -2
- package/crates/team-agent/src/messaging/presentation.rs +307 -0
- package/crates/team-agent/src/messaging/results.rs +130 -3
- package/crates/team-agent/src/messaging/selftest.rs +1 -0
- package/crates/team-agent/src/messaging/send.rs +54 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/watchers.rs +1 -0
- 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 +395 -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/tests.rs +112 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
- 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/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/schemas/result-envelope.schema.json +10 -0
- package/skills/team-agent/SKILL.md +11 -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
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
fn temp_dir(name: &str) -> PathBuf {
|
|
4
|
+
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
5
|
+
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
6
|
+
let dir =
|
|
7
|
+
std::env::temp_dir().join(format!("ta-session-scan-{name}-{}-{n}", std::process::id()));
|
|
8
|
+
std::fs::create_dir_all(&dir).expect("create temp dir");
|
|
9
|
+
dir
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fn write_codex_rollout(path: &Path, cwd: &Path, session_id: &str, embedded_agent_id: &str) {
|
|
13
|
+
let text = format!(
|
|
14
|
+
"{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
|
|
15
|
+
{{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
|
|
16
|
+
{{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
|
|
17
|
+
cwd.to_string_lossy()
|
|
18
|
+
);
|
|
19
|
+
std::fs::write(path, text).expect("write codex rollout");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[test]
|
|
23
|
+
fn codex_prompt_worker_identity_is_a_positive_match() {
|
|
24
|
+
let dir = temp_dir("positive");
|
|
25
|
+
let rollout = dir.join("rollout-frontend.jsonl");
|
|
26
|
+
write_codex_rollout(&rollout, &dir, "sess-frontend", "frontend");
|
|
27
|
+
let context = CaptureSessionContext {
|
|
28
|
+
agent_id: "frontend".to_string(),
|
|
29
|
+
spawn_cwd: dir.clone(),
|
|
30
|
+
pane_id: None,
|
|
31
|
+
pane_pid: None,
|
|
32
|
+
spawned_at: None,
|
|
33
|
+
expected_session_id: None,
|
|
34
|
+
provider_projects_root: None,
|
|
35
|
+
};
|
|
36
|
+
let candidates = parse_candidate_files(
|
|
37
|
+
Provider::Codex,
|
|
38
|
+
&context,
|
|
39
|
+
vec![SessionCandidate {
|
|
40
|
+
path: rollout.clone(),
|
|
41
|
+
requires_cwd_match: false,
|
|
42
|
+
}],
|
|
43
|
+
);
|
|
44
|
+
assert_eq!(candidates.len(), 1);
|
|
45
|
+
assert!(candidates[0].positive_agent_id_match);
|
|
46
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn codex_identity_in_incomplete_final_head_record_is_positive() {
|
|
51
|
+
let dir = temp_dir("cross-cap-positive");
|
|
52
|
+
let rollout = dir.join("rollout-worker-a.jsonl");
|
|
53
|
+
let meta = format!(
|
|
54
|
+
"{{\"session_meta\":{{\"payload\":{{\"id\":\"sess-worker-a\",\"cwd\":\"{}\"}}}}}}\n",
|
|
55
|
+
dir.to_string_lossy()
|
|
56
|
+
);
|
|
57
|
+
let marker = "You are Team Agent worker `worker-a` with role `fixture`.";
|
|
58
|
+
let body = format!("{meta}{{\"text\":\"{marker}{}\"}}\n", "x".repeat(70_000));
|
|
59
|
+
std::fs::write(&rollout, body).expect("write cross-cap rollout");
|
|
60
|
+
assert!(!read_head_text(&rollout, CAPTURE_HEAD_BYTES)
|
|
61
|
+
.expect("read complete records")
|
|
62
|
+
.contains(marker));
|
|
63
|
+
let context = CaptureSessionContext {
|
|
64
|
+
agent_id: "worker-a".to_string(),
|
|
65
|
+
spawn_cwd: dir.clone(),
|
|
66
|
+
pane_id: None,
|
|
67
|
+
pane_pid: None,
|
|
68
|
+
spawned_at: None,
|
|
69
|
+
expected_session_id: None,
|
|
70
|
+
provider_projects_root: None,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let candidates = parse_candidate_files(
|
|
74
|
+
Provider::Codex,
|
|
75
|
+
&context,
|
|
76
|
+
vec![SessionCandidate {
|
|
77
|
+
path: rollout,
|
|
78
|
+
requires_cwd_match: false,
|
|
79
|
+
}],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
assert_eq!(candidates.len(), 1);
|
|
83
|
+
assert_eq!(candidates[0].embedded_agent_id.as_deref(), Some("worker-a"));
|
|
84
|
+
assert!(candidates[0].positive_agent_id_match);
|
|
85
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn codex_prompt_worker_identity_mismatch_is_rejected_for_that_agent() {
|
|
90
|
+
let dir = temp_dir("mismatch");
|
|
91
|
+
let rollout = dir.join("rollout-ios-dev.jsonl");
|
|
92
|
+
write_codex_rollout(&rollout, &dir, "sess-ios-dev", "ios-dev");
|
|
93
|
+
let context = CaptureSessionContext {
|
|
94
|
+
agent_id: "frontend".to_string(),
|
|
95
|
+
spawn_cwd: dir.clone(),
|
|
96
|
+
pane_id: None,
|
|
97
|
+
pane_pid: None,
|
|
98
|
+
spawned_at: None,
|
|
99
|
+
expected_session_id: None,
|
|
100
|
+
provider_projects_root: None,
|
|
101
|
+
};
|
|
102
|
+
let candidates = parse_candidate_files(
|
|
103
|
+
Provider::Codex,
|
|
104
|
+
&context,
|
|
105
|
+
vec![SessionCandidate {
|
|
106
|
+
path: rollout,
|
|
107
|
+
requires_cwd_match: false,
|
|
108
|
+
}],
|
|
109
|
+
);
|
|
110
|
+
assert!(candidates.is_empty());
|
|
111
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
112
|
+
}
|
|
@@ -69,9 +69,11 @@ pub(super) fn parse_candidate_files(
|
|
|
69
69
|
let mut out = Vec::new();
|
|
70
70
|
for candidate in candidates {
|
|
71
71
|
let path = candidate.path;
|
|
72
|
-
let Ok(
|
|
72
|
+
let Ok(head_bytes) = read_head_bytes(&path, CAPTURE_HEAD_BYTES) else {
|
|
73
73
|
continue;
|
|
74
74
|
};
|
|
75
|
+
let text = complete_head_text(&head_bytes);
|
|
76
|
+
let identity_text = String::from_utf8_lossy(&head_bytes);
|
|
75
77
|
let records = parse_session_records(&text);
|
|
76
78
|
if records.is_empty() {
|
|
77
79
|
continue;
|
|
@@ -98,7 +100,7 @@ pub(super) fn parse_candidate_files(
|
|
|
98
100
|
} else {
|
|
99
101
|
Confidence::Low
|
|
100
102
|
};
|
|
101
|
-
let embedded_agent_id = embedded_team_agent_worker_id_from_text(&
|
|
103
|
+
let embedded_agent_id = embedded_team_agent_worker_id_from_text(&identity_text);
|
|
102
104
|
if embedded_agent_id
|
|
103
105
|
.as_deref()
|
|
104
106
|
.is_some_and(|id| id != context.agent_id.as_str())
|
|
@@ -129,7 +131,41 @@ pub(super) fn parse_candidate_files(
|
|
|
129
131
|
out
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/// Reject provider files older than the persisted process-cohort boundary.
|
|
135
|
+
pub(super) fn apply_spawned_at_filter(
|
|
136
|
+
provider: Provider,
|
|
137
|
+
context: &CaptureSessionContext,
|
|
138
|
+
out: &mut Vec<CapturedSessionCandidate>,
|
|
139
|
+
) {
|
|
140
|
+
let Some(raw_spawned_at) = context.spawned_at.as_deref() else {
|
|
141
|
+
// Legacy direct capture_session_id callers have no cohort boundary;
|
|
142
|
+
// the canonical runtime capture path rejects such rows before scan.
|
|
143
|
+
return;
|
|
144
|
+
};
|
|
145
|
+
let Some(spawned_at) = super::codex::parse_spawned_at(raw_spawned_at) else {
|
|
146
|
+
out.clear();
|
|
147
|
+
return;
|
|
148
|
+
};
|
|
149
|
+
out.retain(|candidate| {
|
|
150
|
+
let Some(path) = candidate.captured.rollout_path.as_ref() else {
|
|
151
|
+
return false;
|
|
152
|
+
};
|
|
153
|
+
if matches!(provider, Provider::Codex) {
|
|
154
|
+
return super::codex::rollout_created_at(path.as_path())
|
|
155
|
+
.is_some_and(|created_at| created_at >= spawned_at);
|
|
156
|
+
}
|
|
157
|
+
candidate_mtime(path.as_path()).is_some_and(|mtime| mtime >= spawned_at)
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
fn candidate_mtime(path: &Path) -> Option<std::time::SystemTime> {
|
|
162
|
+
std::fs::metadata(path)
|
|
163
|
+
.and_then(|meta| meta.modified())
|
|
164
|
+
.ok()
|
|
165
|
+
}
|
|
166
|
+
|
|
132
167
|
pub(super) fn apply_spawn_time_window_if_unique(
|
|
168
|
+
provider: Provider,
|
|
133
169
|
context: &CaptureSessionContext,
|
|
134
170
|
out: &mut Vec<CapturedSessionCandidate>,
|
|
135
171
|
) {
|
|
@@ -150,12 +186,11 @@ pub(super) fn apply_spawn_time_window_if_unique(
|
|
|
150
186
|
.captured
|
|
151
187
|
.rollout_path
|
|
152
188
|
.as_ref()
|
|
153
|
-
.and_then(|p| {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.ok()
|
|
189
|
+
.and_then(|p| match provider {
|
|
190
|
+
Provider::Codex => super::codex::rollout_created_at(p.as_path()),
|
|
191
|
+
_ => candidate_mtime(p.as_path()),
|
|
157
192
|
})
|
|
158
|
-
.is_some_and(|
|
|
193
|
+
.is_some_and(|created_at| created_at >= spawned_at)
|
|
159
194
|
})
|
|
160
195
|
.cloned()
|
|
161
196
|
.collect();
|
|
@@ -208,15 +243,23 @@ fn cap_candidates_by_mtime(out: &mut Vec<SessionCandidate>, cap: usize) {
|
|
|
208
243
|
}
|
|
209
244
|
|
|
210
245
|
pub(super) fn read_head_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
|
|
246
|
+
read_head_bytes(path, max_bytes).map(|bytes| complete_head_text(&bytes))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fn read_head_bytes(path: &Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
|
|
211
250
|
use std::io::Read;
|
|
212
251
|
let file = std::fs::File::open(path)?;
|
|
213
252
|
let mut bytes = Vec::new();
|
|
214
253
|
file.take(max_bytes).read_to_end(&mut bytes)?;
|
|
254
|
+
Ok(bytes)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
fn complete_head_text(bytes: &[u8]) -> String {
|
|
215
258
|
let complete = match bytes.iter().rposition(|byte| *byte == b'\n') {
|
|
216
259
|
Some(last_newline) => &bytes[..=last_newline],
|
|
217
260
|
None => &bytes[..],
|
|
218
261
|
};
|
|
219
|
-
|
|
262
|
+
String::from_utf8_lossy(complete).into_owned()
|
|
220
263
|
}
|
|
221
264
|
|
|
222
265
|
fn collect_optional_candidate_files(
|
|
@@ -366,7 +409,7 @@ pub(super) fn record_cwd(record: &serde_json::Value) -> Option<String> {
|
|
|
366
409
|
.map(ToString::to_string)
|
|
367
410
|
}
|
|
368
411
|
|
|
369
|
-
fn paths_equivalent(left: &Path, right: &Path) -> bool {
|
|
412
|
+
pub(super) fn paths_equivalent(left: &Path, right: &Path) -> bool {
|
|
370
413
|
if left == right {
|
|
371
414
|
return true;
|
|
372
415
|
}
|
|
@@ -381,86 +424,4 @@ fn path_is_under_team_runtime(path: &Path) -> bool {
|
|
|
381
424
|
}
|
|
382
425
|
|
|
383
426
|
#[cfg(test)]
|
|
384
|
-
mod tests
|
|
385
|
-
use super::*;
|
|
386
|
-
|
|
387
|
-
fn temp_dir(name: &str) -> PathBuf {
|
|
388
|
-
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
389
|
-
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
390
|
-
let dir =
|
|
391
|
-
std::env::temp_dir().join(format!("ta-session-scan-{name}-{}-{n}", std::process::id()));
|
|
392
|
-
std::fs::create_dir_all(&dir).expect("create temp dir");
|
|
393
|
-
dir
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
fn write_codex_rollout(path: &Path, cwd: &Path, session_id: &str, embedded_agent_id: &str) {
|
|
397
|
-
let text = format!(
|
|
398
|
-
"{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
|
|
399
|
-
{{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
|
|
400
|
-
{{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
|
|
401
|
-
cwd.to_string_lossy()
|
|
402
|
-
);
|
|
403
|
-
std::fs::write(path, text).expect("write codex rollout");
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
#[test]
|
|
407
|
-
fn codex_prompt_worker_identity_is_a_positive_match() {
|
|
408
|
-
let dir = temp_dir("positive");
|
|
409
|
-
let rollout = dir.join("rollout-frontend.jsonl");
|
|
410
|
-
write_codex_rollout(&rollout, &dir, "sess-frontend", "frontend");
|
|
411
|
-
let context = CaptureSessionContext {
|
|
412
|
-
agent_id: "frontend".to_string(),
|
|
413
|
-
spawn_cwd: dir.clone(),
|
|
414
|
-
pane_id: None,
|
|
415
|
-
pane_pid: None,
|
|
416
|
-
spawned_at: None,
|
|
417
|
-
expected_session_id: None,
|
|
418
|
-
provider_projects_root: None,
|
|
419
|
-
};
|
|
420
|
-
let candidates = parse_candidate_files(
|
|
421
|
-
Provider::Codex,
|
|
422
|
-
&context,
|
|
423
|
-
vec![SessionCandidate {
|
|
424
|
-
path: rollout.clone(),
|
|
425
|
-
requires_cwd_match: false,
|
|
426
|
-
}],
|
|
427
|
-
);
|
|
428
|
-
assert_eq!(candidates.len(), 1);
|
|
429
|
-
assert!(
|
|
430
|
-
candidates[0].positive_agent_id_match,
|
|
431
|
-
"Codex transcript prompt identity must be treated as a positive worker id source"
|
|
432
|
-
);
|
|
433
|
-
let _ = std::fs::remove_file(&rollout);
|
|
434
|
-
let _ = std::fs::remove_dir_all(&dir);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
#[test]
|
|
438
|
-
fn codex_prompt_worker_identity_mismatch_is_rejected_for_that_agent() {
|
|
439
|
-
let dir = temp_dir("mismatch");
|
|
440
|
-
let rollout = dir.join("rollout-ios-dev.jsonl");
|
|
441
|
-
write_codex_rollout(&rollout, &dir, "sess-ios-dev", "ios-dev");
|
|
442
|
-
let context = CaptureSessionContext {
|
|
443
|
-
agent_id: "frontend".to_string(),
|
|
444
|
-
spawn_cwd: dir.clone(),
|
|
445
|
-
pane_id: None,
|
|
446
|
-
pane_pid: None,
|
|
447
|
-
spawned_at: None,
|
|
448
|
-
expected_session_id: None,
|
|
449
|
-
provider_projects_root: None,
|
|
450
|
-
};
|
|
451
|
-
let candidates = parse_candidate_files(
|
|
452
|
-
Provider::Codex,
|
|
453
|
-
&context,
|
|
454
|
-
vec![SessionCandidate {
|
|
455
|
-
path: rollout.clone(),
|
|
456
|
-
requires_cwd_match: false,
|
|
457
|
-
}],
|
|
458
|
-
);
|
|
459
|
-
assert!(
|
|
460
|
-
candidates.is_empty(),
|
|
461
|
-
"state agent=frontend must not accept a Codex rollout whose prompt says ios-dev"
|
|
462
|
-
);
|
|
463
|
-
let _ = std::fs::remove_file(&rollout);
|
|
464
|
-
let _ = std::fs::remove_dir_all(&dir);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
427
|
+
mod tests;
|
|
@@ -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
|
}
|