@team-agent/installer 0.3.2 → 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.
- package/Cargo.lock +34 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +196 -19
- package/crates/team-agent/src/cli/diagnose.rs +144 -10
- package/crates/team-agent/src/cli/emit.rs +286 -52
- package/crates/team-agent/src/cli/leader.rs +37 -8
- package/crates/team-agent/src/cli/mod.rs +799 -316
- package/crates/team-agent/src/cli/status_port.rs +25 -2
- package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
- package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
- package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +57 -3
- package/crates/team-agent/src/cli/types.rs +17 -0
- package/crates/team-agent/src/compiler.rs +15 -5
- package/crates/team-agent/src/coordinator/health.rs +89 -20
- package/crates/team-agent/src/coordinator/mod.rs +4 -0
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
- package/crates/team-agent/src/coordinator/tick.rs +222 -69
- package/crates/team-agent/src/coordinator/types.rs +15 -3
- package/crates/team-agent/src/db/schema.rs +37 -2
- package/crates/team-agent/src/diagnose/comms.rs +226 -0
- package/crates/team-agent/src/diagnose/mod.rs +45 -0
- package/crates/team-agent/src/diagnose/orphans.rs +658 -0
- package/crates/team-agent/src/fake_worker.rs +146 -3
- package/crates/team-agent/src/leader/start.rs +121 -23
- package/crates/team-agent/src/leader/types.rs +44 -1
- package/crates/team-agent/src/lib.rs +3 -0
- package/crates/team-agent/src/lifecycle/display.rs +645 -47
- package/crates/team-agent/src/lifecycle/launch.rs +818 -116
- package/crates/team-agent/src/lifecycle/mod.rs +2 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
- package/crates/team-agent/src/lifecycle/restart/common.rs +177 -83
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +443 -9
- package/crates/team-agent/src/lifecycle/restart/remove.rs +22 -6
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
- package/crates/team-agent/src/lifecycle/restart.rs +4 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
- package/crates/team-agent/src/lifecycle/types.rs +19 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
- package/crates/team-agent/src/mcp_server/mod.rs +3 -74
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
- package/crates/team-agent/src/mcp_server/tools.rs +312 -111
- package/crates/team-agent/src/mcp_server/types.rs +6 -4
- package/crates/team-agent/src/mcp_server/wire.rs +19 -7
- package/crates/team-agent/src/message_store.rs +21 -4
- package/crates/team-agent/src/messaging/delivery.rs +87 -37
- package/crates/team-agent/src/messaging/mod.rs +9 -6
- package/crates/team-agent/src/messaging/results.rs +153 -16
- package/crates/team-agent/src/messaging/selftest.rs +199 -12
- package/crates/team-agent/src/messaging/send.rs +35 -3
- package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
- package/crates/team-agent/src/messaging/types.rs +11 -3
- package/crates/team-agent/src/os_probe.rs +119 -0
- package/crates/team-agent/src/packaging/migrate.rs +10 -2
- package/crates/team-agent/src/packaging/tests.rs +23 -0
- package/crates/team-agent/src/provider/adapter.rs +483 -67
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
- package/crates/team-agent/src/provider/classify.rs +51 -4
- package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
- package/crates/team-agent/src/provider/types.rs +47 -0
- package/crates/team-agent/src/session_capture.rs +616 -0
- package/crates/team-agent/src/state/persist.rs +57 -0
- package/crates/team-agent/src/state/projection.rs +32 -23
- package/crates/team-agent/src/state/selector.rs +5 -2
- package/crates/team-agent/src/tmux_backend.rs +97 -60
- package/crates/team-agent/src/transport/test_support.rs +9 -0
- package/crates/team-agent/src/transport/tests/wire.rs +4 -0
- package/crates/team-agent/src/transport.rs +13 -2
- 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
|
+
}
|
|
@@ -274,6 +274,7 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
274
274
|
return;
|
|
275
275
|
}
|
|
276
276
|
preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
|
|
277
|
+
preserve_latest_ownership_fields(incoming, latest);
|
|
277
278
|
|
|
278
279
|
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
279
280
|
if let Some(active_team) = active_team.as_deref() {
|
|
@@ -299,16 +300,72 @@ fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_
|
|
|
299
300
|
latest_entry.get("agents"),
|
|
300
301
|
deleted_agent_ids,
|
|
301
302
|
);
|
|
303
|
+
preserve_latest_ownership_fields(incoming_entry, latest_entry);
|
|
302
304
|
}
|
|
303
305
|
}
|
|
304
306
|
if let Some(active_team) = active_team.as_deref() {
|
|
305
307
|
let latest_top_agents = latest.get("agents");
|
|
306
308
|
if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
|
|
307
309
|
preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
|
|
310
|
+
preserve_latest_ownership_fields(incoming_entry, latest);
|
|
308
311
|
}
|
|
309
312
|
}
|
|
310
313
|
}
|
|
311
314
|
|
|
315
|
+
fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
|
|
316
|
+
if !latest_has_preferable_ownership(incoming, latest) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
let Some(incoming_obj) = incoming.as_object_mut() else {
|
|
320
|
+
return;
|
|
321
|
+
};
|
|
322
|
+
for key in ["leader_receiver", "team_owner", "owner_epoch"] {
|
|
323
|
+
if let Some(value) = latest.get(key).filter(|value| json_truthy(value)) {
|
|
324
|
+
incoming_obj.insert(key.to_string(), value.clone());
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
fn latest_has_preferable_ownership(incoming: &Value, latest: &Value) -> bool {
|
|
330
|
+
let latest_epoch = ownership_epoch(latest);
|
|
331
|
+
let incoming_epoch = ownership_epoch(incoming);
|
|
332
|
+
if latest_epoch > incoming_epoch {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
latest_epoch == incoming_epoch
|
|
336
|
+
&& !ownership_attached(incoming)
|
|
337
|
+
&& ownership_attached(latest)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fn ownership_epoch(state: &Value) -> u64 {
|
|
341
|
+
state
|
|
342
|
+
.get("owner_epoch")
|
|
343
|
+
.and_then(Value::as_u64)
|
|
344
|
+
.or_else(|| {
|
|
345
|
+
state
|
|
346
|
+
.get("team_owner")
|
|
347
|
+
.and_then(|owner| owner.get("owner_epoch"))
|
|
348
|
+
.and_then(Value::as_u64)
|
|
349
|
+
})
|
|
350
|
+
.or_else(|| {
|
|
351
|
+
state
|
|
352
|
+
.get("leader_receiver")
|
|
353
|
+
.and_then(|receiver| receiver.get("owner_epoch"))
|
|
354
|
+
.and_then(Value::as_u64)
|
|
355
|
+
})
|
|
356
|
+
.unwrap_or(0)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
fn ownership_attached(state: &Value) -> bool {
|
|
360
|
+
["leader_receiver", "team_owner"].into_iter().any(|key| {
|
|
361
|
+
state
|
|
362
|
+
.get(key)
|
|
363
|
+
.and_then(|value| value.get("pane_id"))
|
|
364
|
+
.and_then(Value::as_str)
|
|
365
|
+
.is_some_and(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
312
369
|
fn preserve_missing_agents(
|
|
313
370
|
incoming_agents: Option<&mut Value>,
|
|
314
371
|
latest_agents: Option<&Value>,
|