@team-agent/installer 0.4.11 → 0.5.1
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 +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +98 -5
- package/crates/team-agent/src/cli/mod.rs +2 -0
- package/crates/team-agent/src/cli/named_address.rs +864 -0
- package/crates/team-agent/src/cli/send.rs +104 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +4 -0
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- package/package.json +4 -4
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use crate::provider::types::SessionId;
|
|
4
|
+
use crate::provider::Provider;
|
|
5
|
+
|
|
6
|
+
use super::{CaptureSessionContext, CapturedSessionCandidate};
|
|
7
|
+
|
|
8
|
+
pub(super) fn projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<PathBuf> {
|
|
9
|
+
let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
|
|
10
|
+
let encoded = encode_projects_dir(&canonical.to_string_lossy());
|
|
11
|
+
if encoded.is_empty() {
|
|
12
|
+
return None;
|
|
13
|
+
}
|
|
14
|
+
Some(home.join(".claude").join("projects").join(encoded))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fn encode_projects_dir(path: &str) -> String {
|
|
18
|
+
let mut out = String::with_capacity(path.len());
|
|
19
|
+
for c in path.chars() {
|
|
20
|
+
if c.is_ascii_alphanumeric() {
|
|
21
|
+
out.push(c);
|
|
22
|
+
} else {
|
|
23
|
+
out.push('-');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
pub(crate) fn rollout_path_has_leader_marker(provider: Provider, rollout_path: &Path) -> bool {
|
|
30
|
+
if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
let Ok(text) = super::common::read_head_text(rollout_path, super::common::CAPTURE_HEAD_BYTES)
|
|
34
|
+
else {
|
|
35
|
+
return false;
|
|
36
|
+
};
|
|
37
|
+
let records = super::common::parse_session_records(&text);
|
|
38
|
+
records_have_leader_marker(&records)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pub(super) fn records_have_leader_marker(records: &[serde_json::Value]) -> bool {
|
|
42
|
+
records.iter().any(|record| {
|
|
43
|
+
let custom_title = record
|
|
44
|
+
.get("customTitle")
|
|
45
|
+
.and_then(serde_json::Value::as_str)
|
|
46
|
+
.map(str::to_ascii_lowercase);
|
|
47
|
+
let agent_name = record
|
|
48
|
+
.get("agentName")
|
|
49
|
+
.and_then(serde_json::Value::as_str)
|
|
50
|
+
.map(str::to_ascii_lowercase);
|
|
51
|
+
matches!(custom_title.as_deref(), Some("claude leader"))
|
|
52
|
+
|| matches!(agent_name.as_deref(), Some("claude leader"))
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pub(super) fn has_cwd_field(record: &serde_json::Value) -> bool {
|
|
57
|
+
super::common::record_cwd(record).is_some()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub(super) fn apply_expected_session_filter(
|
|
61
|
+
context: &CaptureSessionContext,
|
|
62
|
+
mut out: Vec<CapturedSessionCandidate>,
|
|
63
|
+
) -> Result<Vec<CapturedSessionCandidate>, crate::provider::types::ProviderError> {
|
|
64
|
+
if let Some(expected) = context.expected_session_id.as_ref() {
|
|
65
|
+
if let Some(hit) = out
|
|
66
|
+
.iter()
|
|
67
|
+
.find(|candidate| session_matches(candidate, expected))
|
|
68
|
+
{
|
|
69
|
+
return Ok(vec![hit.clone()]);
|
|
70
|
+
}
|
|
71
|
+
let positive_only: Vec<CapturedSessionCandidate> = out
|
|
72
|
+
.iter()
|
|
73
|
+
.filter(|candidate| candidate.positive_agent_id_match || candidate.agent_path_match)
|
|
74
|
+
.cloned()
|
|
75
|
+
.collect();
|
|
76
|
+
return Ok(positive_only);
|
|
77
|
+
}
|
|
78
|
+
super::common::apply_spawn_time_window_if_unique(context, &mut out);
|
|
79
|
+
Ok(out)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn session_matches(candidate: &CapturedSessionCandidate, expected: &SessionId) -> bool {
|
|
83
|
+
candidate
|
|
84
|
+
.captured
|
|
85
|
+
.session_id
|
|
86
|
+
.as_ref()
|
|
87
|
+
.is_some_and(|session| session.as_str() == expected.as_str())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[cfg(test)]
|
|
91
|
+
mod tests {
|
|
92
|
+
#![allow(clippy::unwrap_used)]
|
|
93
|
+
|
|
94
|
+
use super::*;
|
|
95
|
+
use crate::provider::types::SessionId;
|
|
96
|
+
use crate::provider::Provider;
|
|
97
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
98
|
+
|
|
99
|
+
fn tmp_root(tag: &str) -> PathBuf {
|
|
100
|
+
static CTR: AtomicU64 = AtomicU64::new(0);
|
|
101
|
+
let dir = std::env::temp_dir().join(format!(
|
|
102
|
+
"ta-e6-attr-{}-{}-{}",
|
|
103
|
+
tag,
|
|
104
|
+
std::process::id(),
|
|
105
|
+
CTR.fetch_add(1, Ordering::Relaxed)
|
|
106
|
+
));
|
|
107
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
108
|
+
dir
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn write_transcript(dir: &Path, uuid: &str, cwd: &Path) -> PathBuf {
|
|
112
|
+
std::fs::create_dir_all(dir).unwrap();
|
|
113
|
+
let path = dir.join(format!("{uuid}.jsonl"));
|
|
114
|
+
let line = serde_json::json!({
|
|
115
|
+
"sessionId": uuid,
|
|
116
|
+
"cwd": cwd.to_string_lossy(),
|
|
117
|
+
});
|
|
118
|
+
std::fs::write(&path, format!("{line}\n")).unwrap();
|
|
119
|
+
path
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[test]
|
|
123
|
+
fn claude_leader_marker_in_custom_title_is_detected() {
|
|
124
|
+
let records = vec![serde_json::json!({
|
|
125
|
+
"type": "custom-title",
|
|
126
|
+
"customTitle": "claude leader",
|
|
127
|
+
"sessionId": "ea059b82",
|
|
128
|
+
})];
|
|
129
|
+
assert!(records_have_leader_marker(&records));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[test]
|
|
133
|
+
fn claude_leader_marker_in_agent_name_is_detected() {
|
|
134
|
+
let records = vec![serde_json::json!({
|
|
135
|
+
"type": "agent-name",
|
|
136
|
+
"agentName": "claude leader",
|
|
137
|
+
"sessionId": "ea059b82",
|
|
138
|
+
})];
|
|
139
|
+
assert!(records_have_leader_marker(&records));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn claude_worker_records_have_no_leader_marker() {
|
|
144
|
+
let records = vec![
|
|
145
|
+
serde_json::json!({
|
|
146
|
+
"type": "custom-title",
|
|
147
|
+
"customTitle": "claude release-engineer",
|
|
148
|
+
"sessionId": "abc12345",
|
|
149
|
+
}),
|
|
150
|
+
serde_json::json!({
|
|
151
|
+
"type": "user",
|
|
152
|
+
"content": "Team Agent message from leader: do X",
|
|
153
|
+
"sessionId": "abc12345",
|
|
154
|
+
}),
|
|
155
|
+
];
|
|
156
|
+
assert!(!records_have_leader_marker(&records));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[test]
|
|
160
|
+
fn claude_leader_marker_is_case_insensitive() {
|
|
161
|
+
let records = vec![serde_json::json!({
|
|
162
|
+
"customTitle": "Claude Leader",
|
|
163
|
+
})];
|
|
164
|
+
assert!(records_have_leader_marker(&records));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn claude_projects_dir_for_cwd_encodes_slashes_to_dashes() {
|
|
169
|
+
let home = Path::new("/home/u");
|
|
170
|
+
let cwd = tmp_root("encode");
|
|
171
|
+
let got = projects_dir_for_cwd(home, &cwd).unwrap();
|
|
172
|
+
let canon = std::fs::canonicalize(&cwd).unwrap();
|
|
173
|
+
let expected_leaf = encode_projects_dir(&canon.to_string_lossy());
|
|
174
|
+
assert_eq!(
|
|
175
|
+
got,
|
|
176
|
+
home.join(".claude").join("projects").join(expected_leaf)
|
|
177
|
+
);
|
|
178
|
+
let _ = std::fs::remove_dir_all(&cwd);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#[test]
|
|
182
|
+
fn encode_claude_projects_dir_parity_with_real_claude_naming() {
|
|
183
|
+
assert_eq!(
|
|
184
|
+
encode_projects_dir("/Users/alauda/code"),
|
|
185
|
+
"-Users-alauda-code"
|
|
186
|
+
);
|
|
187
|
+
assert_eq!(
|
|
188
|
+
encode_projects_dir("/Users/alauda/Documents/code/agent前沿探索/多agent协作"),
|
|
189
|
+
"-Users-alauda-Documents-code-agent------agent--"
|
|
190
|
+
);
|
|
191
|
+
assert_eq!(
|
|
192
|
+
encode_projects_dir("/Users/foo bar.baz/v1.2"),
|
|
193
|
+
"-Users-foo-bar-baz-v1-2"
|
|
194
|
+
);
|
|
195
|
+
assert_eq!(
|
|
196
|
+
encode_projects_dir("/proj/.team/runtime"),
|
|
197
|
+
"-proj--team-runtime"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#[test]
|
|
202
|
+
fn scan_expected_session_id_hit_returns_only_that_candidate() {
|
|
203
|
+
let base = tmp_root("c-hit");
|
|
204
|
+
let cwd = base.join("ws");
|
|
205
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
206
|
+
let proj = base.join("projects");
|
|
207
|
+
write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
|
|
208
|
+
write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
|
|
209
|
+
let ctx = CaptureSessionContext {
|
|
210
|
+
agent_id: "w1".to_string(),
|
|
211
|
+
spawn_cwd: cwd.clone(),
|
|
212
|
+
pane_id: None,
|
|
213
|
+
pane_pid: None,
|
|
214
|
+
spawned_at: None,
|
|
215
|
+
expected_session_id: Some(SessionId::new("22222222-2222-4222-8222-222222222222")),
|
|
216
|
+
provider_projects_root: Some(proj.clone()),
|
|
217
|
+
};
|
|
218
|
+
let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
219
|
+
assert_eq!(out.len(), 1);
|
|
220
|
+
assert_eq!(
|
|
221
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
222
|
+
"22222222-2222-4222-8222-222222222222"
|
|
223
|
+
);
|
|
224
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[test]
|
|
228
|
+
fn scan_spawn_time_window_disambiguates_two_siblings() {
|
|
229
|
+
let base = tmp_root("b-window");
|
|
230
|
+
let cwd = base.join("ws");
|
|
231
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
232
|
+
let proj = base.join("projects");
|
|
233
|
+
let old = write_transcript(&proj, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &cwd);
|
|
234
|
+
let new = write_transcript(&proj, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", &cwd);
|
|
235
|
+
let long_ago =
|
|
236
|
+
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
|
|
237
|
+
filetime_set(&old, long_ago);
|
|
238
|
+
let ctx = CaptureSessionContext {
|
|
239
|
+
agent_id: "w1".to_string(),
|
|
240
|
+
spawn_cwd: cwd.clone(),
|
|
241
|
+
pane_id: None,
|
|
242
|
+
pane_pid: None,
|
|
243
|
+
spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
|
|
244
|
+
expected_session_id: None,
|
|
245
|
+
provider_projects_root: Some(proj.clone()),
|
|
246
|
+
};
|
|
247
|
+
let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
248
|
+
assert_eq!(out.len(), 1);
|
|
249
|
+
assert_eq!(
|
|
250
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
251
|
+
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
|
252
|
+
);
|
|
253
|
+
let _ = new;
|
|
254
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#[test]
|
|
258
|
+
fn scan_no_spawned_at_keeps_both_siblings_ambiguous() {
|
|
259
|
+
let base = tmp_root("b-noamb");
|
|
260
|
+
let cwd = base.join("ws");
|
|
261
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
262
|
+
let proj = base.join("projects");
|
|
263
|
+
write_transcript(&proj, "cccccccc-cccc-4ccc-8ccc-cccccccccccc", &cwd);
|
|
264
|
+
write_transcript(&proj, "dddddddd-dddd-4ddd-8ddd-dddddddddddd", &cwd);
|
|
265
|
+
let ctx = CaptureSessionContext {
|
|
266
|
+
agent_id: "w1".to_string(),
|
|
267
|
+
spawn_cwd: cwd.clone(),
|
|
268
|
+
pane_id: None,
|
|
269
|
+
pane_pid: None,
|
|
270
|
+
spawned_at: None,
|
|
271
|
+
expected_session_id: None,
|
|
272
|
+
provider_projects_root: Some(proj.clone()),
|
|
273
|
+
};
|
|
274
|
+
let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
275
|
+
assert!(out.len() >= 2);
|
|
276
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#[test]
|
|
280
|
+
fn scan_expected_session_id_miss_refuses_to_pick_leader_sibling() {
|
|
281
|
+
let base = tmp_root("strict-no-leader-fallback");
|
|
282
|
+
let cwd = base.join("ws");
|
|
283
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
284
|
+
let proj = base.join("projects");
|
|
285
|
+
let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
|
|
286
|
+
let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
|
|
287
|
+
let _ = (leader, stale);
|
|
288
|
+
let ctx = CaptureSessionContext {
|
|
289
|
+
agent_id: "claude-worker".to_string(),
|
|
290
|
+
spawn_cwd: cwd.clone(),
|
|
291
|
+
pane_id: None,
|
|
292
|
+
pane_pid: None,
|
|
293
|
+
spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
|
|
294
|
+
expected_session_id: Some(SessionId::new("99999999-9999-4999-8999-999999999999")),
|
|
295
|
+
provider_projects_root: Some(proj.clone()),
|
|
296
|
+
};
|
|
297
|
+
let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
|
|
298
|
+
assert!(
|
|
299
|
+
out.is_empty(),
|
|
300
|
+
"expected-id miss must not fall back to latest"
|
|
301
|
+
);
|
|
302
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
fn filetime_set(path: &Path, when: std::time::SystemTime) {
|
|
306
|
+
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
|
307
|
+
f.set_modified(when).unwrap();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
use super::{CaptureSessionContext, CapturedSessionCandidate};
|
|
2
|
+
|
|
3
|
+
pub(super) fn apply_spawned_at_filter(
|
|
4
|
+
out: &mut Vec<CapturedSessionCandidate>,
|
|
5
|
+
context: &CaptureSessionContext,
|
|
6
|
+
) {
|
|
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
|
+
out.retain(|candidate| {
|
|
13
|
+
let path = match candidate.captured.rollout_path.as_ref() {
|
|
14
|
+
Some(p) => p.as_path(),
|
|
15
|
+
None => return false,
|
|
16
|
+
};
|
|
17
|
+
std::fs::metadata(path)
|
|
18
|
+
.and_then(|meta| meta.modified())
|
|
19
|
+
.map(|mtime| mtime >= cutoff)
|
|
20
|
+
.unwrap_or(false)
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
|
|
25
|
+
chrono::DateTime::parse_from_rfc3339(raw)
|
|
26
|
+
.ok()
|
|
27
|
+
.map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[cfg(test)]
|
|
31
|
+
mod tests {
|
|
32
|
+
use super::*;
|
|
33
|
+
|
|
34
|
+
#[test]
|
|
35
|
+
fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
|
|
36
|
+
assert!(parse_spawned_at("2026-06-10T21:40:00+00:00").is_some());
|
|
37
|
+
assert!(parse_spawned_at("not-a-date").is_none());
|
|
38
|
+
assert!(parse_spawned_at("").is_none());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use crate::provider::helpers::{find_session_id, parse_jsonl_records};
|
|
4
|
+
use crate::provider::types::{
|
|
5
|
+
CaptureVia, CapturedSession, Confidence, ProviderError, RolloutPath, SessionId,
|
|
6
|
+
};
|
|
7
|
+
use crate::provider::Provider;
|
|
8
|
+
|
|
9
|
+
use super::{CaptureSessionContext, CapturedSessionCandidate};
|
|
10
|
+
|
|
11
|
+
pub(super) struct SessionCandidate {
|
|
12
|
+
pub(super) path: PathBuf,
|
|
13
|
+
requires_cwd_match: bool,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// P2 (C-P2-2/3) / Python claude.py:300 — candidates are capped to the newest `cap`
|
|
17
|
+
/// by mtime (descending priority: old candidates must not crowd out new ones).
|
|
18
|
+
const CAPTURE_CANDIDATE_CAP: usize = 300;
|
|
19
|
+
|
|
20
|
+
/// P2 (C-P2-1): head window >= Python's 200-line read.
|
|
21
|
+
pub(super) const CAPTURE_HEAD_BYTES: u64 = 65_536;
|
|
22
|
+
|
|
23
|
+
pub(super) fn candidate_session_files(
|
|
24
|
+
provider: Provider,
|
|
25
|
+
context: &CaptureSessionContext,
|
|
26
|
+
) -> Result<Vec<SessionCandidate>, ProviderError> {
|
|
27
|
+
let mut out = Vec::new();
|
|
28
|
+
if let Some(root) = context.provider_projects_root.as_ref() {
|
|
29
|
+
collect_optional_candidate_files(root, &context.agent_id, &mut out)?;
|
|
30
|
+
}
|
|
31
|
+
collect_candidate_files(&context.spawn_cwd, &context.agent_id, 0, false, &mut out)?;
|
|
32
|
+
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
|
33
|
+
match provider {
|
|
34
|
+
Provider::Codex => {
|
|
35
|
+
collect_optional_candidate_files(
|
|
36
|
+
&home.join(".codex").join("sessions"),
|
|
37
|
+
&context.agent_id,
|
|
38
|
+
&mut out,
|
|
39
|
+
)?;
|
|
40
|
+
}
|
|
41
|
+
Provider::Claude | Provider::ClaudeCode => {
|
|
42
|
+
if let Some(dir) = super::claude::projects_dir_for_cwd(&home, &context.spawn_cwd) {
|
|
43
|
+
collect_optional_candidate_files(&dir, &context.agent_id, &mut out)?;
|
|
44
|
+
}
|
|
45
|
+
collect_optional_candidate_files(
|
|
46
|
+
&home.join(".claude").join("projects"),
|
|
47
|
+
&context.agent_id,
|
|
48
|
+
&mut out,
|
|
49
|
+
)?;
|
|
50
|
+
}
|
|
51
|
+
Provider::Copilot | Provider::GeminiCli | Provider::Fake => {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
out.sort_by(|a, b| {
|
|
55
|
+
a.requires_cwd_match
|
|
56
|
+
.cmp(&b.requires_cwd_match)
|
|
57
|
+
.then_with(|| a.path.to_string_lossy().cmp(&b.path.to_string_lossy()))
|
|
58
|
+
});
|
|
59
|
+
out.dedup_by(|a, b| a.path == b.path && a.requires_cwd_match == b.requires_cwd_match);
|
|
60
|
+
cap_candidates_by_mtime(&mut out, CAPTURE_CANDIDATE_CAP);
|
|
61
|
+
Ok(out)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub(super) fn parse_candidate_files(
|
|
65
|
+
provider: Provider,
|
|
66
|
+
context: &CaptureSessionContext,
|
|
67
|
+
candidates: Vec<SessionCandidate>,
|
|
68
|
+
) -> Vec<CapturedSessionCandidate> {
|
|
69
|
+
let mut out = Vec::new();
|
|
70
|
+
for candidate in candidates {
|
|
71
|
+
let path = candidate.path;
|
|
72
|
+
let Ok(text) = read_head_text(&path, CAPTURE_HEAD_BYTES) else {
|
|
73
|
+
continue;
|
|
74
|
+
};
|
|
75
|
+
let records = parse_session_records(&text);
|
|
76
|
+
if records.is_empty() {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if candidate.requires_cwd_match
|
|
80
|
+
&& !provider_home_records_match_spawn_cwd(&records, &context.spawn_cwd)
|
|
81
|
+
{
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
let session_id = records.iter().find_map(find_session_id);
|
|
85
|
+
if matches!(provider, Provider::Claude | Provider::ClaudeCode)
|
|
86
|
+
&& session_id.is_some()
|
|
87
|
+
&& !records.iter().any(super::claude::has_cwd_field)
|
|
88
|
+
{
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
let captured_via = if session_id.is_some() {
|
|
92
|
+
CaptureVia::FsWatch
|
|
93
|
+
} else {
|
|
94
|
+
CaptureVia::FsMtimeFallback
|
|
95
|
+
};
|
|
96
|
+
let attribution_confidence = if session_id.is_some() {
|
|
97
|
+
Confidence::High
|
|
98
|
+
} else {
|
|
99
|
+
Confidence::Low
|
|
100
|
+
};
|
|
101
|
+
let positive_agent_id_match = candidate_text_has_team_agent_id(&text, context);
|
|
102
|
+
let agent_path_match = candidate_path_matches_agent_id(&path, context);
|
|
103
|
+
if matches!(provider, Provider::Claude | Provider::ClaudeCode)
|
|
104
|
+
&& super::claude::records_have_leader_marker(&records)
|
|
105
|
+
{
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
out.push(CapturedSessionCandidate {
|
|
109
|
+
captured: CapturedSession {
|
|
110
|
+
session_id: session_id.map(SessionId::new),
|
|
111
|
+
rollout_path: Some(RolloutPath::new(path)),
|
|
112
|
+
captured_via,
|
|
113
|
+
attribution_confidence,
|
|
114
|
+
spawn_cwd: context.spawn_cwd.clone(),
|
|
115
|
+
},
|
|
116
|
+
positive_agent_id_match,
|
|
117
|
+
agent_path_match,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pub(super) fn apply_spawn_time_window_if_unique(
|
|
124
|
+
context: &CaptureSessionContext,
|
|
125
|
+
out: &mut Vec<CapturedSessionCandidate>,
|
|
126
|
+
) {
|
|
127
|
+
if context.expected_session_id.is_some() && out.len() <= 1 {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
let Some(spawned_at) = context
|
|
131
|
+
.spawned_at
|
|
132
|
+
.as_deref()
|
|
133
|
+
.and_then(super::codex::parse_spawned_at)
|
|
134
|
+
else {
|
|
135
|
+
return;
|
|
136
|
+
};
|
|
137
|
+
let within: Vec<CapturedSessionCandidate> = out
|
|
138
|
+
.iter()
|
|
139
|
+
.filter(|candidate| {
|
|
140
|
+
candidate
|
|
141
|
+
.captured
|
|
142
|
+
.rollout_path
|
|
143
|
+
.as_ref()
|
|
144
|
+
.and_then(|p| {
|
|
145
|
+
std::fs::metadata(p.as_path())
|
|
146
|
+
.and_then(|m| m.modified())
|
|
147
|
+
.ok()
|
|
148
|
+
})
|
|
149
|
+
.is_some_and(|mtime| mtime >= spawned_at)
|
|
150
|
+
})
|
|
151
|
+
.cloned()
|
|
152
|
+
.collect();
|
|
153
|
+
if within.len() == 1 {
|
|
154
|
+
*out = within;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pub(super) fn sort_expected_first_if_needed(
|
|
159
|
+
context: &CaptureSessionContext,
|
|
160
|
+
out: &mut [CapturedSessionCandidate],
|
|
161
|
+
) {
|
|
162
|
+
if let Some(expected) = context.expected_session_id.as_ref() {
|
|
163
|
+
out.sort_by_key(|candidate| {
|
|
164
|
+
candidate
|
|
165
|
+
.captured
|
|
166
|
+
.session_id
|
|
167
|
+
.as_ref()
|
|
168
|
+
.is_none_or(|session| session.as_str() != expected.as_str())
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fn cap_candidates_by_mtime(out: &mut Vec<SessionCandidate>, cap: usize) {
|
|
174
|
+
if out.len() <= cap {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let mut ranked: Vec<(std::time::SystemTime, usize)> = out
|
|
178
|
+
.iter()
|
|
179
|
+
.enumerate()
|
|
180
|
+
.map(|(index, candidate)| {
|
|
181
|
+
let mtime = std::fs::metadata(&candidate.path)
|
|
182
|
+
.and_then(|meta| meta.modified())
|
|
183
|
+
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
|
184
|
+
(mtime, index)
|
|
185
|
+
})
|
|
186
|
+
.collect();
|
|
187
|
+
ranked.sort_by(|a, b| b.0.cmp(&a.0));
|
|
188
|
+
let keep: std::collections::BTreeSet<usize> = ranked
|
|
189
|
+
.into_iter()
|
|
190
|
+
.take(cap)
|
|
191
|
+
.map(|(_, index)| index)
|
|
192
|
+
.collect();
|
|
193
|
+
let mut index = 0;
|
|
194
|
+
out.retain(|_| {
|
|
195
|
+
let kept = keep.contains(&index);
|
|
196
|
+
index += 1;
|
|
197
|
+
kept
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
pub(super) fn read_head_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
|
|
202
|
+
use std::io::Read;
|
|
203
|
+
let file = std::fs::File::open(path)?;
|
|
204
|
+
let mut bytes = Vec::new();
|
|
205
|
+
file.take(max_bytes).read_to_end(&mut bytes)?;
|
|
206
|
+
let complete = match bytes.iter().rposition(|byte| *byte == b'\n') {
|
|
207
|
+
Some(last_newline) => &bytes[..=last_newline],
|
|
208
|
+
None => &bytes[..],
|
|
209
|
+
};
|
|
210
|
+
Ok(String::from_utf8_lossy(complete).into_owned())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
fn collect_optional_candidate_files(
|
|
214
|
+
dir: &Path,
|
|
215
|
+
agent_id: &str,
|
|
216
|
+
out: &mut Vec<SessionCandidate>,
|
|
217
|
+
) -> Result<(), ProviderError> {
|
|
218
|
+
if dir.exists() {
|
|
219
|
+
let _ = collect_candidate_files(dir, agent_id, 0, true, out);
|
|
220
|
+
}
|
|
221
|
+
Ok(())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn collect_candidate_files(
|
|
225
|
+
dir: &Path,
|
|
226
|
+
agent_id: &str,
|
|
227
|
+
depth: usize,
|
|
228
|
+
requires_cwd_match: bool,
|
|
229
|
+
out: &mut Vec<SessionCandidate>,
|
|
230
|
+
) -> Result<(), ProviderError> {
|
|
231
|
+
if depth > 4 {
|
|
232
|
+
return Ok(());
|
|
233
|
+
}
|
|
234
|
+
let entries = match std::fs::read_dir(dir) {
|
|
235
|
+
Ok(entries) => entries,
|
|
236
|
+
Err(e) if depth == 0 => return Err(ProviderError::Io(format!("{}: {e}", dir.display()))),
|
|
237
|
+
Err(_) => return Ok(()),
|
|
238
|
+
};
|
|
239
|
+
for entry in entries {
|
|
240
|
+
let Ok(entry) = entry else {
|
|
241
|
+
continue;
|
|
242
|
+
};
|
|
243
|
+
let path = entry.path();
|
|
244
|
+
if path.is_dir() {
|
|
245
|
+
collect_candidate_files(
|
|
246
|
+
&path,
|
|
247
|
+
agent_id,
|
|
248
|
+
depth.saturating_add(1),
|
|
249
|
+
requires_cwd_match,
|
|
250
|
+
out,
|
|
251
|
+
)?;
|
|
252
|
+
} else if looks_like_session_file(&path, agent_id) {
|
|
253
|
+
out.push(SessionCandidate {
|
|
254
|
+
path,
|
|
255
|
+
requires_cwd_match,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
Ok(())
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fn looks_like_session_file(path: &Path, agent_id: &str) -> bool {
|
|
263
|
+
if path_is_under_team_runtime(path) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
|
|
267
|
+
return false;
|
|
268
|
+
};
|
|
269
|
+
name.ends_with(".jsonl")
|
|
270
|
+
|| name.ends_with(".json")
|
|
271
|
+
|| name.contains("session")
|
|
272
|
+
|| name.contains("rollout")
|
|
273
|
+
|| (!agent_id.is_empty() && name.contains(agent_id))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pub(super) fn parse_session_records(text: &str) -> Vec<serde_json::Value> {
|
|
277
|
+
match serde_json::from_str::<serde_json::Value>(text) {
|
|
278
|
+
Ok(serde_json::Value::Array(items)) => items,
|
|
279
|
+
Ok(value) => vec![value],
|
|
280
|
+
Err(_) => parse_jsonl_records(text),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
fn provider_home_records_match_spawn_cwd(records: &[serde_json::Value], spawn_cwd: &Path) -> bool {
|
|
285
|
+
let cwd_values: Vec<String> = records.iter().filter_map(record_cwd).collect();
|
|
286
|
+
!cwd_values.is_empty()
|
|
287
|
+
&& cwd_values
|
|
288
|
+
.iter()
|
|
289
|
+
.any(|cwd| paths_equivalent(Path::new(cwd), spawn_cwd))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
fn candidate_text_has_team_agent_id(text: &str, context: &CaptureSessionContext) -> bool {
|
|
293
|
+
let id = context.agent_id.as_str();
|
|
294
|
+
if id.is_empty() {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
[
|
|
298
|
+
format!("\"TEAM_AGENT_ID\":\"{id}\""),
|
|
299
|
+
format!("\"TEAM_AGENT_ID\": \"{id}\""),
|
|
300
|
+
format!("TEAM_AGENT_ID={id}"),
|
|
301
|
+
format!("env.TEAM_AGENT_ID=\"{id}\""),
|
|
302
|
+
format!("env.TEAM_AGENT_ID=\\\"{id}\\\""),
|
|
303
|
+
format!("\"TEAM_AGENT_AGENT_ID\":\"{id}\""),
|
|
304
|
+
format!("\"TEAM_AGENT_AGENT_ID\": \"{id}\""),
|
|
305
|
+
format!("TEAM_AGENT_AGENT_ID={id}"),
|
|
306
|
+
]
|
|
307
|
+
.iter()
|
|
308
|
+
.any(|needle| text.contains(needle))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fn candidate_path_matches_agent_id(path: &Path, context: &CaptureSessionContext) -> bool {
|
|
312
|
+
let id = context.agent_id.as_str();
|
|
313
|
+
if id.is_empty() {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
|
317
|
+
return false;
|
|
318
|
+
};
|
|
319
|
+
let dashed = id.replace('_', "-");
|
|
320
|
+
name.contains(id) || name.contains(&dashed)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
pub(super) fn record_cwd(record: &serde_json::Value) -> Option<String> {
|
|
324
|
+
record
|
|
325
|
+
.get("cwd")
|
|
326
|
+
.and_then(serde_json::Value::as_str)
|
|
327
|
+
.or_else(|| {
|
|
328
|
+
record
|
|
329
|
+
.get("session_meta")
|
|
330
|
+
.and_then(|v| v.get("payload"))
|
|
331
|
+
.or_else(|| record.get("payload"))
|
|
332
|
+
.and_then(|v| v.get("cwd"))
|
|
333
|
+
.and_then(serde_json::Value::as_str)
|
|
334
|
+
})
|
|
335
|
+
.map(ToString::to_string)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
fn paths_equivalent(left: &Path, right: &Path) -> bool {
|
|
339
|
+
if left == right {
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
|
|
343
|
+
let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
|
|
344
|
+
left == right || left.parent().is_some_and(|parent| parent == right)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fn path_is_under_team_runtime(path: &Path) -> bool {
|
|
348
|
+
path.components()
|
|
349
|
+
.any(|c| c.as_os_str() == std::ffi::OsStr::new(".team"))
|
|
350
|
+
}
|