@team-agent/installer 0.3.1 → 0.3.3

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 +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -0,0 +1,616 @@
1
+ use std::collections::{BTreeMap, BTreeSet};
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use serde_json::Value;
5
+
6
+ use crate::provider::{
7
+ CapturedSession, CapturedSessionCandidate, CaptureSessionContext, Provider, ProviderAdapter,
8
+ ProviderError, SessionId,
9
+ };
10
+
11
+ pub const SESSION_CAPTURE_CONVERGENCE_DEADLINE_MS: u64 = 12_000;
12
+ pub const SESSION_CAPTURE_CONVERGENCE_POLL_MS: u64 = 250;
13
+ pub const RESTART_SESSION_CONVERGENCE_DEADLINE_MS: u64 = SESSION_CAPTURE_CONVERGENCE_DEADLINE_MS;
14
+ pub const RESTART_SESSION_CONVERGENCE_POLL_MS: u64 = SESSION_CAPTURE_CONVERGENCE_POLL_MS;
15
+
16
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
17
+ pub struct CapturePassReport {
18
+ pub changed: bool,
19
+ pub pending: Vec<String>,
20
+ pub assigned: Vec<String>,
21
+ pub ambiguous: Vec<AmbiguousSessionCapture>,
22
+ pub candidate_count_by_agent: BTreeMap<String, usize>,
23
+ }
24
+
25
+ #[derive(Debug, Clone, PartialEq, Eq)]
26
+ pub struct AmbiguousSessionCapture {
27
+ pub agent_id: String,
28
+ pub spawn_cwd: String,
29
+ }
30
+
31
+ #[derive(Debug, Clone, PartialEq, Eq)]
32
+ pub struct SessionConvergence {
33
+ pub converged: bool,
34
+ pub changed: bool,
35
+ pub missing: Vec<String>,
36
+ pub deadline: std::time::Duration,
37
+ pub elapsed: std::time::Duration,
38
+ }
39
+
40
+ #[derive(Debug, Clone, PartialEq, Eq)]
41
+ pub struct SessionConvergenceProgress {
42
+ pub iteration: u64,
43
+ pub elapsed_ms: u128,
44
+ pub deadline_ms: u128,
45
+ pub remaining_ms: u128,
46
+ pub changed: bool,
47
+ pub assigned: Vec<String>,
48
+ pub missing: Vec<String>,
49
+ pub required_missing_agent_ids: Vec<String>,
50
+ pub pending_agent_ids: Vec<String>,
51
+ pub candidate_count_by_agent: BTreeMap<String, usize>,
52
+ }
53
+
54
+ /// Bounded session convergence barrier for destructive lifecycle gates.
55
+ ///
56
+ /// This is intentionally not one opportunistic capture pass and not an
57
+ /// unbounded wait: callers must pass an explicit `deadline` and `poll_interval`.
58
+ /// Each poll runs the shared allocator once, reports progress, and sleeps until
59
+ /// either all required agents have provider sessions or the deadline expires.
60
+ pub fn converge_missing_provider_sessions<F, M, P>(
61
+ state: &mut Value,
62
+ adapter_for: &mut F,
63
+ deadline: std::time::Duration,
64
+ poll_interval: std::time::Duration,
65
+ mut missing_agent_ids: M,
66
+ mut progress: P,
67
+ ) -> Result<SessionConvergence, String>
68
+ where
69
+ F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
70
+ M: FnMut(&Value) -> Vec<String>,
71
+ P: FnMut(SessionConvergenceProgress) -> Result<(), String>,
72
+ {
73
+ let start = std::time::Instant::now();
74
+ let deadline_at = start + deadline;
75
+ let mut changed = false;
76
+ let mut iteration = 0_u64;
77
+ loop {
78
+ let timeout_s = poll_interval.as_secs().max(1);
79
+ let required_missing = missing_agent_ids(state);
80
+ let report = capture_missing_provider_sessions_once(state, adapter_for, false, timeout_s)
81
+ .map_err(|e| e.to_string())?;
82
+ changed |= report.changed;
83
+ let missing = missing_agent_ids(state);
84
+ progress(SessionConvergenceProgress {
85
+ iteration,
86
+ elapsed_ms: start.elapsed().as_millis(),
87
+ deadline_ms: deadline.as_millis(),
88
+ remaining_ms: deadline_at
89
+ .saturating_duration_since(std::time::Instant::now())
90
+ .as_millis(),
91
+ changed: report.changed,
92
+ assigned: report.assigned,
93
+ missing: missing.clone(),
94
+ required_missing_agent_ids: required_missing,
95
+ pending_agent_ids: missing.clone(),
96
+ candidate_count_by_agent: report.candidate_count_by_agent.clone(),
97
+ })?;
98
+ if missing.is_empty() {
99
+ if !report.ambiguous.is_empty() {
100
+ let final_report = capture_missing_provider_sessions_once(state, adapter_for, true, timeout_s)
101
+ .map_err(|e| e.to_string())?;
102
+ changed |= final_report.changed;
103
+ }
104
+ return Ok(SessionConvergence {
105
+ converged: true,
106
+ changed,
107
+ missing,
108
+ deadline,
109
+ elapsed: start.elapsed(),
110
+ });
111
+ }
112
+ let now = std::time::Instant::now();
113
+ if now >= deadline_at {
114
+ return Ok(SessionConvergence {
115
+ converged: false,
116
+ changed,
117
+ missing: missing_agent_ids(state),
118
+ deadline,
119
+ elapsed: start.elapsed(),
120
+ });
121
+ }
122
+ std::thread::sleep(std::cmp::min(
123
+ poll_interval,
124
+ deadline_at.saturating_duration_since(now),
125
+ ));
126
+ iteration += 1;
127
+ }
128
+ }
129
+
130
+ pub fn capture_missing_provider_sessions_once<F>(
131
+ state: &mut Value,
132
+ adapter_for: &mut F,
133
+ finalize_ambiguous: bool,
134
+ timeout_s: u64,
135
+ ) -> Result<CapturePassReport, ProviderError>
136
+ where
137
+ F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
138
+ {
139
+ let Some(agent_map) = state.get("agents").and_then(Value::as_object) else {
140
+ return Ok(CapturePassReport::default());
141
+ };
142
+ let mut pending = Vec::new();
143
+ let mut candidates_by_agent = BTreeMap::new();
144
+ for (agent_id, agent) in agent_map {
145
+ let Some(capture) = pending_session_capture(agent_id, agent, adapter_for) else {
146
+ continue;
147
+ };
148
+ let adapter = adapter_for(capture.provider);
149
+ let candidates = adapter.capture_session_candidates(&capture.context, timeout_s)?;
150
+ candidates_by_agent.insert(capture.agent_id.clone(), candidates);
151
+ pending.push(capture);
152
+ }
153
+
154
+ let pending_ids = pending
155
+ .iter()
156
+ .map(|item| item.agent_id.clone())
157
+ .collect::<BTreeSet<_>>();
158
+ let mut claimed = claimed_provider_session_keys(agent_map, &pending_ids);
159
+ let (assignments, ambiguous_ids) =
160
+ allocate_session_candidates(&pending, &candidates_by_agent, &mut claimed);
161
+
162
+ let Some(agents) = state.get_mut("agents").and_then(Value::as_object_mut) else {
163
+ return Ok(CapturePassReport::default());
164
+ };
165
+ let mut report = CapturePassReport {
166
+ pending: pending.iter().map(|item| item.agent_id.clone()).collect(),
167
+ candidate_count_by_agent: candidates_by_agent
168
+ .iter()
169
+ .map(|(agent_id, candidates)| (agent_id.clone(), candidates.len()))
170
+ .collect(),
171
+ ..CapturePassReport::default()
172
+ };
173
+ for item in pending {
174
+ let Some(agent_obj) = agents.get_mut(&item.agent_id).and_then(Value::as_object_mut) else {
175
+ continue;
176
+ };
177
+ if let Some(candidate) = assignments.get(&item.agent_id) {
178
+ apply_captured_session(agent_obj, &candidate.captured);
179
+ report.changed = true;
180
+ report.assigned.push(item.agent_id);
181
+ continue;
182
+ }
183
+ if ambiguous_ids.contains(&item.agent_id) {
184
+ report.ambiguous.push(AmbiguousSessionCapture {
185
+ agent_id: item.agent_id.clone(),
186
+ spawn_cwd: item.context.spawn_cwd.to_string_lossy().to_string(),
187
+ });
188
+ if finalize_ambiguous {
189
+ agent_obj.insert("attribution_ambiguous".to_string(), serde_json::json!(true));
190
+ agent_obj.insert(
191
+ "captured_at".to_string(),
192
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
193
+ );
194
+ report.changed = true;
195
+ }
196
+ }
197
+ }
198
+ Ok(report)
199
+ }
200
+
201
+ pub fn incomplete_resumable_agent_ids(state: &Value) -> Vec<String> {
202
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
203
+ return Vec::new();
204
+ };
205
+ let mut out = agents
206
+ .iter()
207
+ .filter_map(|(agent_id, agent)| {
208
+ if pending_session_capture(agent_id, agent, &mut crate::provider::get_adapter).is_some() {
209
+ Some(agent_id.clone())
210
+ } else {
211
+ None
212
+ }
213
+ })
214
+ .collect::<Vec<_>>();
215
+ out.sort();
216
+ out
217
+ }
218
+
219
+ pub fn session_capture_complete(state: &Value) -> bool {
220
+ incomplete_resumable_agent_ids(state).is_empty()
221
+ }
222
+
223
+ pub fn recover_resume_session_from_events(
224
+ workspace: &Path,
225
+ agent_id: &str,
226
+ previous: &Value,
227
+ adapter: &dyn ProviderAdapter,
228
+ auth_mode: crate::provider::AuthMode,
229
+ exclude_session_ids: &BTreeSet<String>,
230
+ ) -> Result<Option<Value>, ProviderError> {
231
+ let events = crate::event_log::EventLog::new(workspace)
232
+ .tail(0)
233
+ .map_err(|e| ProviderError::Io(e.to_string()))?;
234
+ let current_session = previous
235
+ .get("session_id")
236
+ .and_then(Value::as_str)
237
+ .filter(|session| !session.is_empty());
238
+ for event in events.iter().rev() {
239
+ if !event_matches_agent(event, agent_id) {
240
+ continue;
241
+ }
242
+ match event.get("event").and_then(Value::as_str) {
243
+ Some("discard.session_tombstone") => return Ok(None),
244
+ Some("session.captured") => {}
245
+ _ => continue,
246
+ }
247
+ let Some(session_id) = event
248
+ .get("session_id")
249
+ .and_then(Value::as_str)
250
+ .filter(|session| !session.is_empty())
251
+ else {
252
+ continue;
253
+ };
254
+ if current_session == Some(session_id) || exclude_session_ids.contains(session_id) {
255
+ continue;
256
+ }
257
+ let Some(rollout_path) = event_rollout_path(event).filter(|path| path.exists()) else {
258
+ continue;
259
+ };
260
+ let session = SessionId::new(session_id.to_string());
261
+ if !adapter.session_is_resumable(Some(&session), auth_mode)? {
262
+ continue;
263
+ }
264
+ let mut repaired = previous.clone();
265
+ if !repaired.is_object() {
266
+ repaired = serde_json::json!({});
267
+ }
268
+ let Some(obj) = repaired.as_object_mut() else {
269
+ continue;
270
+ };
271
+ obj.insert("session_id".to_string(), serde_json::json!(session_id));
272
+ obj.insert(
273
+ "rollout_path".to_string(),
274
+ serde_json::json!(rollout_path.to_string_lossy().to_string()),
275
+ );
276
+ if let Some(ts) = event.get("ts").and_then(Value::as_str).filter(|ts| !ts.is_empty()) {
277
+ obj.insert("captured_at".to_string(), serde_json::json!(ts));
278
+ }
279
+ obj.insert(
280
+ "captured_via".to_string(),
281
+ serde_json::json!("event_log_repair"),
282
+ );
283
+ if let Some(confidence) = event.get("attribution_confidence").cloned() {
284
+ obj.insert("attribution_confidence".to_string(), confidence);
285
+ }
286
+ obj.remove("attribution_ambiguous");
287
+ return Ok(Some(repaired));
288
+ }
289
+ Ok(None)
290
+ }
291
+
292
+ fn event_matches_agent(event: &Value, agent_id: &str) -> bool {
293
+ ["agent_id", "worker_id"]
294
+ .iter()
295
+ .any(|key| event.get(*key).and_then(Value::as_str) == Some(agent_id))
296
+ }
297
+
298
+ fn event_rollout_path(event: &Value) -> Option<PathBuf> {
299
+ event
300
+ .get("rollout_path")
301
+ .or_else(|| event.get("transcript_path"))
302
+ .and_then(Value::as_str)
303
+ .filter(|path| !path.is_empty())
304
+ .map(PathBuf::from)
305
+ }
306
+
307
+ pub fn incomplete_interacted_resumable_agent_ids(state: &Value) -> Vec<String> {
308
+ let mut out = incomplete_resumable_agent_ids(state)
309
+ .into_iter()
310
+ .filter(|agent_id| {
311
+ state
312
+ .get("agents")
313
+ .and_then(|agents| agents.get(agent_id))
314
+ .and_then(|agent| agent.get("first_send_at"))
315
+ .and_then(Value::as_str)
316
+ .is_some_and(|value| !value.is_empty())
317
+ })
318
+ .collect::<Vec<_>>();
319
+ out.sort();
320
+ out
321
+ }
322
+
323
+ struct PendingSessionCapture {
324
+ agent_id: String,
325
+ provider: Provider,
326
+ context: CaptureSessionContext,
327
+ }
328
+
329
+ fn pending_session_capture<F>(
330
+ agent_id: &str,
331
+ agent: &Value,
332
+ adapter_for: &mut F,
333
+ ) -> Option<PendingSessionCapture>
334
+ where
335
+ F: FnMut(Provider) -> Box<dyn ProviderAdapter>,
336
+ {
337
+ if agent
338
+ .get("status")
339
+ .and_then(Value::as_str)
340
+ .is_some_and(|status| status != "running")
341
+ {
342
+ return None;
343
+ }
344
+ if agent_session_complete(agent) {
345
+ return None;
346
+ }
347
+ let provider = agent
348
+ .get("provider")
349
+ .and_then(Value::as_str)
350
+ .and_then(parse_provider)?;
351
+ let spawn_cwd = agent
352
+ .get("spawn_cwd")
353
+ .and_then(Value::as_str)
354
+ .filter(|cwd| !cwd.is_empty())?;
355
+ if !adapter_for(provider).caps().resume {
356
+ return None;
357
+ }
358
+ Some(PendingSessionCapture {
359
+ agent_id: agent_id.to_string(),
360
+ provider,
361
+ context: CaptureSessionContext {
362
+ agent_id: agent_id.to_string(),
363
+ spawn_cwd: PathBuf::from(spawn_cwd),
364
+ pane_id: agent
365
+ .get("pane_id")
366
+ .and_then(Value::as_str)
367
+ .filter(|pane| !pane.is_empty())
368
+ .map(str::to_string),
369
+ pane_pid: agent
370
+ .get("pane_pid")
371
+ .and_then(Value::as_u64)
372
+ .and_then(|pid| u32::try_from(pid).ok()),
373
+ spawned_at: agent
374
+ .get("spawned_at")
375
+ .and_then(Value::as_str)
376
+ .filter(|value| !value.is_empty())
377
+ .map(str::to_string),
378
+ expected_session_id: agent
379
+ .get("_pending_session_id")
380
+ .and_then(Value::as_str)
381
+ .filter(|value| !value.is_empty())
382
+ .map(SessionId::new),
383
+ provider_projects_root: agent
384
+ .get("claude_projects_root")
385
+ .and_then(Value::as_str)
386
+ .filter(|value| !value.is_empty())
387
+ .map(PathBuf::from),
388
+ },
389
+ })
390
+ }
391
+
392
+ fn agent_session_complete(agent: &Value) -> bool {
393
+ agent
394
+ .get("session_id")
395
+ .and_then(Value::as_str)
396
+ .is_some_and(|session| !session.is_empty())
397
+ && agent
398
+ .get("rollout_path")
399
+ .and_then(Value::as_str)
400
+ .is_some_and(|path| !path.is_empty())
401
+ }
402
+
403
+ fn allocate_session_candidates(
404
+ pending: &[PendingSessionCapture],
405
+ candidates_by_agent: &BTreeMap<String, Vec<CapturedSessionCandidate>>,
406
+ claimed: &mut BTreeSet<String>,
407
+ ) -> (BTreeMap<String, CapturedSessionCandidate>, BTreeSet<String>) {
408
+ let mut assignments = BTreeMap::new();
409
+ let mut ambiguous = BTreeSet::new();
410
+ for item in pending {
411
+ if let Some(candidate) = unique_available_candidate(
412
+ candidates_by_agent.get(&item.agent_id),
413
+ claimed,
414
+ CandidateMatchKind::PositiveAgentId,
415
+ ) {
416
+ claimed.extend(captured_provider_session_keys(&candidate.captured));
417
+ assignments.insert(item.agent_id.clone(), candidate);
418
+ }
419
+ }
420
+ for item in pending {
421
+ if assignments.contains_key(&item.agent_id) {
422
+ continue;
423
+ }
424
+ if let Some(candidate) = unique_available_candidate(
425
+ candidates_by_agent.get(&item.agent_id),
426
+ claimed,
427
+ CandidateMatchKind::PathAgentId,
428
+ ) {
429
+ claimed.extend(captured_provider_session_keys(&candidate.captured));
430
+ assignments.insert(item.agent_id.clone(), candidate);
431
+ }
432
+ }
433
+ allocate_global_one_to_one(pending, candidates_by_agent, claimed, &mut assignments);
434
+ for item in pending {
435
+ if assignments.contains_key(&item.agent_id) {
436
+ continue;
437
+ }
438
+ match unique_available_candidate(
439
+ candidates_by_agent.get(&item.agent_id),
440
+ claimed,
441
+ CandidateMatchKind::Any,
442
+ ) {
443
+ Some(candidate) => {
444
+ claimed.extend(captured_provider_session_keys(&candidate.captured));
445
+ assignments.insert(item.agent_id.clone(), candidate);
446
+ }
447
+ None => {
448
+ if candidates_by_agent
449
+ .get(&item.agent_id)
450
+ .is_some_and(|candidates| !candidates.is_empty())
451
+ {
452
+ ambiguous.insert(item.agent_id.clone());
453
+ }
454
+ }
455
+ }
456
+ }
457
+ (assignments, ambiguous)
458
+ }
459
+
460
+ fn allocate_global_one_to_one(
461
+ pending: &[PendingSessionCapture],
462
+ candidates_by_agent: &BTreeMap<String, Vec<CapturedSessionCandidate>>,
463
+ claimed: &mut BTreeSet<String>,
464
+ assignments: &mut BTreeMap<String, CapturedSessionCandidate>,
465
+ ) {
466
+ let remaining_agents = pending
467
+ .iter()
468
+ .filter(|item| !assignments.contains_key(&item.agent_id))
469
+ .map(|item| item.agent_id.clone())
470
+ .collect::<Vec<_>>();
471
+ if remaining_agents.is_empty() {
472
+ return;
473
+ }
474
+ let mut candidates = BTreeMap::new();
475
+ for agent_id in &remaining_agents {
476
+ let Some(agent_candidates) = candidates_by_agent.get(agent_id) else {
477
+ return;
478
+ };
479
+ for candidate in agent_candidates {
480
+ if candidate_keys_collide(candidate, claimed) {
481
+ continue;
482
+ }
483
+ let key = candidate_key(candidate);
484
+ if key.is_empty() {
485
+ continue;
486
+ }
487
+ candidates.entry(key).or_insert_with(|| candidate.clone());
488
+ }
489
+ }
490
+ if candidates.len() != remaining_agents.len() {
491
+ return;
492
+ }
493
+ for (agent_id, candidate) in remaining_agents.into_iter().zip(candidates.into_values()) {
494
+ claimed.extend(captured_provider_session_keys(&candidate.captured));
495
+ assignments.insert(agent_id, candidate);
496
+ }
497
+ }
498
+
499
+ fn unique_available_candidate(
500
+ candidates: Option<&Vec<CapturedSessionCandidate>>,
501
+ claimed: &BTreeSet<String>,
502
+ match_kind: CandidateMatchKind,
503
+ ) -> Option<CapturedSessionCandidate> {
504
+ let matches = candidates?
505
+ .iter()
506
+ .filter(|candidate| match match_kind {
507
+ CandidateMatchKind::PositiveAgentId => candidate.positive_agent_id_match,
508
+ CandidateMatchKind::PathAgentId => candidate.agent_path_match,
509
+ CandidateMatchKind::Any => true,
510
+ })
511
+ .filter(|candidate| !candidate_keys_collide(candidate, claimed))
512
+ .cloned()
513
+ .collect::<Vec<_>>();
514
+ if matches.len() == 1 {
515
+ matches.into_iter().next()
516
+ } else {
517
+ None
518
+ }
519
+ }
520
+
521
+ #[derive(Clone, Copy)]
522
+ enum CandidateMatchKind {
523
+ PositiveAgentId,
524
+ PathAgentId,
525
+ Any,
526
+ }
527
+
528
+ fn candidate_keys_collide(candidate: &CapturedSessionCandidate, claimed: &BTreeSet<String>) -> bool {
529
+ captured_provider_session_keys(&candidate.captured)
530
+ .iter()
531
+ .any(|key| claimed.contains(key))
532
+ }
533
+
534
+ fn candidate_key(candidate: &CapturedSessionCandidate) -> String {
535
+ captured_provider_session_keys(&candidate.captured)
536
+ .into_iter()
537
+ .collect::<Vec<_>>()
538
+ .join("|")
539
+ }
540
+
541
+ fn apply_captured_session(agent_obj: &mut serde_json::Map<String, Value>, captured: &CapturedSession) {
542
+ if let Some(session_id) = &captured.session_id {
543
+ agent_obj.insert("session_id".to_string(), serde_json::json!(session_id.as_str()));
544
+ }
545
+ if let Some(rollout_path) = &captured.rollout_path {
546
+ agent_obj.insert(
547
+ "rollout_path".to_string(),
548
+ serde_json::json!(rollout_path.as_path().to_string_lossy()),
549
+ );
550
+ }
551
+ agent_obj.insert(
552
+ "captured_at".to_string(),
553
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
554
+ );
555
+ agent_obj.insert(
556
+ "captured_via".to_string(),
557
+ serde_json::to_value(captured.captured_via).unwrap_or(Value::Null),
558
+ );
559
+ agent_obj.insert(
560
+ "attribution_confidence".to_string(),
561
+ serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
562
+ );
563
+ agent_obj.remove("attribution_ambiguous");
564
+ }
565
+
566
+ fn claimed_provider_session_keys(
567
+ agents: &serde_json::Map<String, Value>,
568
+ pending_ids: &BTreeSet<String>,
569
+ ) -> BTreeSet<String> {
570
+ let mut keys = BTreeSet::new();
571
+ for (agent_id, agent) in agents {
572
+ if pending_ids.contains(agent_id) {
573
+ continue;
574
+ }
575
+ if let Some(session_id) = agent
576
+ .get("session_id")
577
+ .and_then(Value::as_str)
578
+ .filter(|s| !s.is_empty())
579
+ {
580
+ keys.insert(format!("session:{session_id}"));
581
+ }
582
+ if let Some(rollout_path) = agent
583
+ .get("rollout_path")
584
+ .and_then(Value::as_str)
585
+ .filter(|s| !s.is_empty())
586
+ {
587
+ keys.insert(format!("rollout:{rollout_path}"));
588
+ }
589
+ }
590
+ keys
591
+ }
592
+
593
+ fn captured_provider_session_keys(captured: &CapturedSession) -> BTreeSet<String> {
594
+ let mut keys = BTreeSet::new();
595
+ if let Some(session_id) = &captured.session_id {
596
+ keys.insert(format!("session:{}", session_id.as_str()));
597
+ }
598
+ if let Some(rollout_path) = &captured.rollout_path {
599
+ keys.insert(format!(
600
+ "rollout:{}",
601
+ rollout_path.as_path().to_string_lossy()
602
+ ));
603
+ }
604
+ keys
605
+ }
606
+
607
+ fn parse_provider(raw: &str) -> Option<Provider> {
608
+ match raw {
609
+ "claude" => Some(Provider::Claude),
610
+ "claude_code" => Some(Provider::ClaudeCode),
611
+ "codex" => Some(Provider::Codex),
612
+ "gemini_cli" => Some(Provider::GeminiCli),
613
+ "fake" => Some(Provider::Fake),
614
+ _ => None,
615
+ }
616
+ }