@team-agent/installer 0.3.1 → 0.3.2
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 +38 -7
- package/crates/team-agent/src/cli/emit.rs +7 -6
- package/crates/team-agent/src/cli/mod.rs +623 -21
- package/crates/team-agent/src/cli/status_port.rs +170 -44
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/health.rs +9 -0
- package/crates/team-agent/src/lifecycle/launch.rs +271 -58
- package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
- package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
- package/crates/team-agent/src/lifecycle/restart.rs +20 -0
- package/crates/team-agent/src/messaging/delivery.rs +397 -36
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/results.rs +200 -47
- package/crates/team-agent/src/provider/adapter.rs +95 -10
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/state/persist.rs +113 -1
- package/crates/team-agent/src/state/projection.rs +127 -3
- package/crates/team-agent/src/tmux_backend.rs +66 -6
- package/package.json +4 -4
|
@@ -10,6 +10,7 @@ use crate::message_store::MessageStore;
|
|
|
10
10
|
use super::helpers::{next_result_id, required_str, validate_result_envelope};
|
|
11
11
|
use super::types::SEND_RETRY_MAX_ATTEMPTS;
|
|
12
12
|
use crate::model::ids::TaskId;
|
|
13
|
+
use crate::state::projection::OwnerTeamResolution;
|
|
13
14
|
use super::watchers::retry_result_deliveries;
|
|
14
15
|
use super::MessagingError;
|
|
15
16
|
|
|
@@ -19,26 +20,65 @@ pub fn collect(
|
|
|
19
20
|
workspace: &Path,
|
|
20
21
|
result_file: Option<&Path>,
|
|
21
22
|
ensure_coordinator: bool,
|
|
23
|
+
) -> Result<serde_json::Value, MessagingError> {
|
|
24
|
+
collect_scoped(workspace, result_file, ensure_coordinator, None)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
pub fn collect_for_team(
|
|
28
|
+
workspace: &Path,
|
|
29
|
+
result_file: Option<&Path>,
|
|
30
|
+
ensure_coordinator: bool,
|
|
31
|
+
owner_team_id: Option<&str>,
|
|
32
|
+
) -> Result<serde_json::Value, MessagingError> {
|
|
33
|
+
collect_scoped(workspace, result_file, ensure_coordinator, owner_team_id)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn collect_scoped(
|
|
37
|
+
workspace: &Path,
|
|
38
|
+
result_file: Option<&Path>,
|
|
39
|
+
ensure_coordinator: bool,
|
|
40
|
+
owner_team_id: Option<&str>,
|
|
22
41
|
) -> Result<serde_json::Value, MessagingError> {
|
|
23
42
|
let _ = ensure_coordinator;
|
|
24
43
|
let paths = collect_paths(workspace)?;
|
|
25
|
-
let
|
|
44
|
+
let log = EventLog::new(&paths.run_workspace);
|
|
45
|
+
let resolved_owner_team_id = match owner_team_id.filter(|team| !team.is_empty()) {
|
|
46
|
+
Some(team) => Some(resolve_owner_team_for_read(&paths.run_workspace, team, Some(&log))?),
|
|
47
|
+
None => None,
|
|
48
|
+
};
|
|
49
|
+
let owner_team_id = resolved_owner_team_id.as_deref();
|
|
50
|
+
let mut state = match owner_team_id {
|
|
51
|
+
Some(team) => crate::state::projection::select_runtime_state(&paths.run_workspace, Some(team))?,
|
|
52
|
+
None => crate::state::persist::load_runtime_state(&paths.run_workspace)?,
|
|
53
|
+
};
|
|
54
|
+
let spec_workspace = owner_team_id
|
|
55
|
+
.and_then(|_| state_spec_workspace_from_value(&state))
|
|
56
|
+
.unwrap_or_else(|| paths.spec_workspace.clone());
|
|
57
|
+
let spec_path = spec_workspace.join("team.spec.yaml");
|
|
26
58
|
if !spec_path.exists() {
|
|
27
59
|
return Err(MessagingError::Validation(format!("Cannot read {}", spec_path.display())));
|
|
28
60
|
}
|
|
29
61
|
let store = MessageStore::open(&paths.run_workspace)?;
|
|
30
62
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
31
63
|
if let Some(path) = result_file {
|
|
32
|
-
ingest_result_file(&conn, path)?;
|
|
64
|
+
ingest_result_file(&conn, path, owner_team_id)?;
|
|
33
65
|
}
|
|
34
|
-
let
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
let sql = match owner_team_id {
|
|
67
|
+
Some(_) => {
|
|
68
|
+
"select result_id, task_id, agent_id, envelope, status, created_at
|
|
69
|
+
from results
|
|
70
|
+
where status not in ('collected', 'invalid') and owner_team_id = ?1
|
|
71
|
+
order by created_at, result_id"
|
|
72
|
+
}
|
|
73
|
+
None => {
|
|
74
|
+
"select result_id, task_id, agent_id, envelope, status, created_at
|
|
75
|
+
from results
|
|
76
|
+
where status not in ('collected', 'invalid')
|
|
77
|
+
order by created_at, result_id"
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
let mut stmt = conn.prepare(sql)?;
|
|
81
|
+
let row_mapper = |row: &rusqlite::Row<'_>| {
|
|
42
82
|
Ok(StoredResult {
|
|
43
83
|
result_id: row.get(0)?,
|
|
44
84
|
task_id: row.get(1)?,
|
|
@@ -47,16 +87,18 @@ pub fn collect(
|
|
|
47
87
|
status: row.get(4)?,
|
|
48
88
|
created_at: row.get(5)?,
|
|
49
89
|
})
|
|
50
|
-
}
|
|
90
|
+
};
|
|
91
|
+
let rows = match owner_team_id {
|
|
92
|
+
Some(team) => stmt.query_map(params![team], row_mapper),
|
|
93
|
+
None => stmt.query_map([], row_mapper),
|
|
94
|
+
}?
|
|
51
95
|
.collect::<Result<Vec<_>, _>>()?;
|
|
52
96
|
drop(stmt);
|
|
53
97
|
|
|
54
|
-
let mut state = crate::state::persist::load_runtime_state(&paths.run_workspace)?;
|
|
55
98
|
let mut collected = Vec::new();
|
|
56
99
|
let mut collected_results = Vec::new();
|
|
57
100
|
let mut invalid_results = Vec::new();
|
|
58
101
|
let mut state_dirty = false;
|
|
59
|
-
let log = EventLog::new(&paths.run_workspace);
|
|
60
102
|
for row in rows {
|
|
61
103
|
let envelope: serde_json::Value = match serde_json::from_str(&row.envelope) {
|
|
62
104
|
Ok(envelope) => envelope,
|
|
@@ -83,7 +125,7 @@ pub fn collect(
|
|
|
83
125
|
}
|
|
84
126
|
let scope = if task_exists(&state, &row.task_id) {
|
|
85
127
|
"task"
|
|
86
|
-
} else if is_message_scoped_result(&conn, &row.task_id, &row.agent_id)? {
|
|
128
|
+
} else if is_message_scoped_result(&conn, &row.task_id, &row.agent_id, owner_team_id)? {
|
|
87
129
|
"message"
|
|
88
130
|
} else {
|
|
89
131
|
record_invalid_result(
|
|
@@ -95,10 +137,20 @@ pub fn collect(
|
|
|
95
137
|
)?;
|
|
96
138
|
continue;
|
|
97
139
|
};
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
140
|
+
match owner_team_id {
|
|
141
|
+
Some(team) => {
|
|
142
|
+
conn.execute(
|
|
143
|
+
"update results set status = 'collected' where result_id = ?1 and owner_team_id = ?2",
|
|
144
|
+
params![row.result_id.as_str(), team],
|
|
145
|
+
)?;
|
|
146
|
+
}
|
|
147
|
+
None => {
|
|
148
|
+
conn.execute(
|
|
149
|
+
"update results set status = 'collected' where result_id = ?1",
|
|
150
|
+
params![row.result_id.as_str()],
|
|
151
|
+
)?;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
102
154
|
if scope == "task" {
|
|
103
155
|
mark_task_done(&mut state, &row.task_id, &row.result_id);
|
|
104
156
|
state_dirty = true;
|
|
@@ -129,9 +181,13 @@ pub fn collect(
|
|
|
129
181
|
collected_results.push(summary);
|
|
130
182
|
}
|
|
131
183
|
if state_dirty {
|
|
132
|
-
|
|
184
|
+
if owner_team_id.is_some() {
|
|
185
|
+
crate::state::projection::save_team_scoped_state(&paths.run_workspace, &state)?;
|
|
186
|
+
} else {
|
|
187
|
+
crate::state::persist::save_runtime_state(&paths.run_workspace, &state)?;
|
|
188
|
+
}
|
|
133
189
|
}
|
|
134
|
-
let counts = result_counts(&conn)?;
|
|
190
|
+
let counts = result_counts(&conn, owner_team_id)?;
|
|
135
191
|
Ok(serde_json::json!({
|
|
136
192
|
"ok": invalid_results.is_empty(),
|
|
137
193
|
"collected": collected,
|
|
@@ -139,7 +195,7 @@ pub fn collect(
|
|
|
139
195
|
"delivered_messages": [],
|
|
140
196
|
"invalid_results": invalid_results,
|
|
141
197
|
"results": counts,
|
|
142
|
-
"state_file":
|
|
198
|
+
"state_file": spec_workspace.join("team_state.md").to_string_lossy().to_string(),
|
|
143
199
|
"coordinator": {
|
|
144
200
|
"ok": false,
|
|
145
201
|
"status": "not_required",
|
|
@@ -147,12 +203,48 @@ pub fn collect(
|
|
|
147
203
|
}))
|
|
148
204
|
}
|
|
149
205
|
|
|
206
|
+
fn resolve_owner_team_for_read(
|
|
207
|
+
workspace: &Path,
|
|
208
|
+
requested: &str,
|
|
209
|
+
event_log: Option<&EventLog>,
|
|
210
|
+
) -> Result<String, MessagingError> {
|
|
211
|
+
let state = crate::state::persist::load_runtime_state(workspace)?;
|
|
212
|
+
match crate::state::projection::resolve_owner_team_id(&state, requested) {
|
|
213
|
+
OwnerTeamResolution::Canonical(canonical) => Ok(canonical),
|
|
214
|
+
OwnerTeamResolution::LegacyAlias { requested, canonical } => {
|
|
215
|
+
crate::messaging::delivery::normalize_owner_team_id_rows(
|
|
216
|
+
workspace,
|
|
217
|
+
&requested,
|
|
218
|
+
&canonical,
|
|
219
|
+
None,
|
|
220
|
+
event_log,
|
|
221
|
+
)?;
|
|
222
|
+
Ok(canonical)
|
|
223
|
+
}
|
|
224
|
+
OwnerTeamResolution::Unresolved { requested } => {
|
|
225
|
+
Err(MessagingError::Routing(format!("owner_team_unresolved: {requested}")))
|
|
226
|
+
}
|
|
227
|
+
OwnerTeamResolution::Ambiguous { requested, matches } => {
|
|
228
|
+
Err(MessagingError::Routing(format!(
|
|
229
|
+
"owner_team_ambiguous: {requested} matches {}",
|
|
230
|
+
matches.join(",")
|
|
231
|
+
)))
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
150
236
|
struct CollectPaths {
|
|
151
237
|
run_workspace: PathBuf,
|
|
152
238
|
spec_workspace: PathBuf,
|
|
153
239
|
}
|
|
154
240
|
|
|
155
241
|
fn collect_paths(workspace: &Path) -> Result<CollectPaths, MessagingError> {
|
|
242
|
+
if collect_input_has_no_local_team_context(workspace) {
|
|
243
|
+
return Ok(CollectPaths {
|
|
244
|
+
run_workspace: workspace.to_path_buf(),
|
|
245
|
+
spec_workspace: workspace.to_path_buf(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
156
248
|
let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
|
|
157
249
|
.map_err(|e| MessagingError::Routing(e.to_string()))?;
|
|
158
250
|
let spec_workspace = if workspace.join("team.spec.yaml").exists() {
|
|
@@ -168,8 +260,24 @@ fn collect_paths(workspace: &Path) -> Result<CollectPaths, MessagingError> {
|
|
|
168
260
|
})
|
|
169
261
|
}
|
|
170
262
|
|
|
263
|
+
fn collect_input_has_no_local_team_context(workspace: &Path) -> bool {
|
|
264
|
+
!workspace.join("team.spec.yaml").exists()
|
|
265
|
+
&& !workspace.join(".team").exists()
|
|
266
|
+
&& !crate::state::persist::runtime_state_path(workspace).exists()
|
|
267
|
+
&& workspace.file_name().and_then(|s| s.to_str()) != Some(".team")
|
|
268
|
+
&& workspace
|
|
269
|
+
.parent()
|
|
270
|
+
.and_then(|p| p.file_name())
|
|
271
|
+
.and_then(|s| s.to_str())
|
|
272
|
+
!= Some(".team")
|
|
273
|
+
}
|
|
274
|
+
|
|
171
275
|
fn state_spec_workspace(run_workspace: &Path) -> Option<PathBuf> {
|
|
172
276
|
let state = crate::state::persist::load_runtime_state(run_workspace).ok()?;
|
|
277
|
+
state_spec_workspace_from_value(&state)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fn state_spec_workspace_from_value(state: &serde_json::Value) -> Option<PathBuf> {
|
|
173
281
|
if let Some(spec_path) = state.get("spec_path").and_then(serde_json::Value::as_str) {
|
|
174
282
|
return PathBuf::from(spec_path).parent().map(Path::to_path_buf);
|
|
175
283
|
}
|
|
@@ -200,7 +308,11 @@ fn record_invalid_result(
|
|
|
200
308
|
Ok(())
|
|
201
309
|
}
|
|
202
310
|
|
|
203
|
-
fn ingest_result_file(
|
|
311
|
+
fn ingest_result_file(
|
|
312
|
+
conn: &rusqlite::Connection,
|
|
313
|
+
path: &Path,
|
|
314
|
+
owner_team_id: Option<&str>,
|
|
315
|
+
) -> Result<(), MessagingError> {
|
|
204
316
|
let raw = std::fs::read_to_string(path)?;
|
|
205
317
|
let mut envelope: serde_json::Value = serde_json::from_str(&raw)?;
|
|
206
318
|
validate_result_envelope(&envelope)?;
|
|
@@ -226,7 +338,7 @@ fn ingest_result_file(conn: &rusqlite::Connection, path: &Path) -> Result<(), Me
|
|
|
226
338
|
agent_id,
|
|
227
339
|
&envelope.to_string(),
|
|
228
340
|
status,
|
|
229
|
-
|
|
341
|
+
owner_team_id,
|
|
230
342
|
)?;
|
|
231
343
|
Ok(())
|
|
232
344
|
}
|
|
@@ -273,39 +385,55 @@ fn is_message_scoped_result(
|
|
|
273
385
|
conn: &rusqlite::Connection,
|
|
274
386
|
task_id: &str,
|
|
275
387
|
agent_id: &str,
|
|
388
|
+
owner_team_id: Option<&str>,
|
|
276
389
|
) -> Result<bool, MessagingError> {
|
|
277
390
|
if !task_id.starts_with("msg_") {
|
|
278
391
|
return Ok(false);
|
|
279
392
|
}
|
|
280
|
-
let count: i64 =
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
393
|
+
let count: i64 = match owner_team_id {
|
|
394
|
+
Some(team) => conn.query_row(
|
|
395
|
+
"select count(*) from messages where message_id = ?1 and recipient = ?2 and owner_team_id = ?3",
|
|
396
|
+
params![task_id, agent_id, team],
|
|
397
|
+
|row| row.get(0),
|
|
398
|
+
)?,
|
|
399
|
+
None => conn.query_row(
|
|
400
|
+
"select count(*) from messages where message_id = ?1 and recipient = ?2",
|
|
401
|
+
params![task_id, agent_id],
|
|
402
|
+
|row| row.get(0),
|
|
403
|
+
)?,
|
|
404
|
+
};
|
|
285
405
|
Ok(count > 0)
|
|
286
406
|
}
|
|
287
407
|
|
|
288
|
-
fn result_counts(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
)?;
|
|
295
|
-
let invalid: i64 = conn.query_row(
|
|
296
|
-
"select count(*) from results where status = 'invalid'",
|
|
297
|
-
[],
|
|
298
|
-
|row| row.get(0),
|
|
299
|
-
)?;
|
|
408
|
+
fn result_counts(
|
|
409
|
+
conn: &rusqlite::Connection,
|
|
410
|
+
owner_team_id: Option<&str>,
|
|
411
|
+
) -> Result<serde_json::Value, MessagingError> {
|
|
412
|
+
let total: i64 = count_results(conn, owner_team_id, None)?;
|
|
413
|
+
let collected: i64 = count_results(conn, owner_team_id, Some("collected"))?;
|
|
414
|
+
let invalid: i64 = count_results(conn, owner_team_id, Some("invalid"))?;
|
|
300
415
|
let uncollected = total - collected - invalid;
|
|
301
416
|
let mut by_status = serde_json::Map::new();
|
|
302
|
-
let
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
417
|
+
let sql = match owner_team_id {
|
|
418
|
+
Some(_) => {
|
|
419
|
+
"select status, count(*) from results
|
|
420
|
+
where status not in ('collected', 'invalid') and owner_team_id = ?1
|
|
421
|
+
group by status
|
|
422
|
+
order by status"
|
|
423
|
+
}
|
|
424
|
+
None => {
|
|
425
|
+
"select status, count(*) from results
|
|
426
|
+
where status not in ('collected', 'invalid')
|
|
427
|
+
group by status
|
|
428
|
+
order by status"
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
let mut stmt = conn.prepare(sql)?;
|
|
432
|
+
let row_mapper = |row: &rusqlite::Row<'_>| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?));
|
|
433
|
+
let rows = match owner_team_id {
|
|
434
|
+
Some(team) => stmt.query_map(params![team], row_mapper),
|
|
435
|
+
None => stmt.query_map([], row_mapper),
|
|
436
|
+
}?;
|
|
309
437
|
for row in rows {
|
|
310
438
|
let (status, count) = row?;
|
|
311
439
|
by_status.insert(status, serde_json::Value::Number(count.into()));
|
|
@@ -319,6 +447,31 @@ fn result_counts(conn: &rusqlite::Connection) -> Result<serde_json::Value, Messa
|
|
|
319
447
|
}))
|
|
320
448
|
}
|
|
321
449
|
|
|
450
|
+
fn count_results(
|
|
451
|
+
conn: &rusqlite::Connection,
|
|
452
|
+
owner_team_id: Option<&str>,
|
|
453
|
+
status: Option<&str>,
|
|
454
|
+
) -> Result<i64, MessagingError> {
|
|
455
|
+
match (owner_team_id, status) {
|
|
456
|
+
(Some(team), Some(status)) => Ok(conn.query_row(
|
|
457
|
+
"select count(*) from results where owner_team_id = ?1 and status = ?2",
|
|
458
|
+
params![team, status],
|
|
459
|
+
|row| row.get(0),
|
|
460
|
+
)?),
|
|
461
|
+
(Some(team), None) => Ok(conn.query_row(
|
|
462
|
+
"select count(*) from results where owner_team_id = ?1",
|
|
463
|
+
params![team],
|
|
464
|
+
|row| row.get(0),
|
|
465
|
+
)?),
|
|
466
|
+
(None, Some(status)) => Ok(conn.query_row(
|
|
467
|
+
"select count(*) from results where status = ?1",
|
|
468
|
+
params![status],
|
|
469
|
+
|row| row.get(0),
|
|
470
|
+
)?),
|
|
471
|
+
(None, None) => Ok(conn.query_row("select count(*) from results", [], |row| row.get(0))?),
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
322
475
|
/// `report_result` (`results.py:191`):worker 报结果 —— 校验 envelope、存 result、ack 任务消息、
|
|
323
476
|
/// **排队** send 事件通知 leader、推进 orchestrator (软依赖,失败仅记 `orchestrator.advance_skipped`)。
|
|
324
477
|
/// MCP `report_result` 工具调。
|
|
@@ -277,15 +277,19 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
277
277
|
spawn_cwd: &Path,
|
|
278
278
|
_timeout_s: u64,
|
|
279
279
|
) -> Result<Option<CapturedSession>, ProviderError> {
|
|
280
|
-
let candidates = candidate_session_files(spawn_cwd, agent_id)?;
|
|
281
|
-
for
|
|
280
|
+
let candidates = candidate_session_files(self.provider, spawn_cwd, agent_id)?;
|
|
281
|
+
for candidate in candidates {
|
|
282
|
+
let path = candidate.path;
|
|
282
283
|
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
283
284
|
continue;
|
|
284
285
|
};
|
|
285
|
-
let records =
|
|
286
|
+
let records = parse_session_records(&text);
|
|
286
287
|
if records.is_empty() {
|
|
287
288
|
continue;
|
|
288
289
|
}
|
|
290
|
+
if candidate.requires_cwd_match && !provider_home_records_match_spawn_cwd(&records, spawn_cwd) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
289
293
|
let session_id = records.iter().find_map(find_session_id);
|
|
290
294
|
if matches!(self.provider, Provider::Claude | Provider::ClaudeCode)
|
|
291
295
|
&& session_id.is_some()
|
|
@@ -518,18 +522,56 @@ fn command_on_path(name: &str) -> bool {
|
|
|
518
522
|
std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
|
|
519
523
|
}
|
|
520
524
|
|
|
521
|
-
|
|
525
|
+
struct SessionCandidate {
|
|
526
|
+
path: PathBuf,
|
|
527
|
+
requires_cwd_match: bool,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
fn candidate_session_files(
|
|
531
|
+
provider: Provider,
|
|
532
|
+
spawn_cwd: &Path,
|
|
533
|
+
agent_id: &str,
|
|
534
|
+
) -> Result<Vec<SessionCandidate>, ProviderError> {
|
|
522
535
|
let mut out = Vec::new();
|
|
523
|
-
collect_candidate_files(spawn_cwd, agent_id, 0, &mut out)?;
|
|
524
|
-
|
|
536
|
+
collect_candidate_files(spawn_cwd, agent_id, 0, false, &mut out)?;
|
|
537
|
+
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
|
538
|
+
match provider {
|
|
539
|
+
Provider::Codex => {
|
|
540
|
+
collect_optional_candidate_files(&home.join(".codex").join("sessions"), agent_id, &mut out)?;
|
|
541
|
+
}
|
|
542
|
+
Provider::Claude | Provider::ClaudeCode => {
|
|
543
|
+
collect_optional_candidate_files(&home.join(".claude").join("sessions"), agent_id, &mut out)?;
|
|
544
|
+
collect_optional_candidate_files(&home.join(".claude").join("projects"), agent_id, &mut out)?;
|
|
545
|
+
}
|
|
546
|
+
Provider::GeminiCli | Provider::Fake => {}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
out.sort_by(|a, b| {
|
|
550
|
+
a.requires_cwd_match
|
|
551
|
+
.cmp(&b.requires_cwd_match)
|
|
552
|
+
.then_with(|| a.path.to_string_lossy().cmp(&b.path.to_string_lossy()))
|
|
553
|
+
});
|
|
554
|
+
out.dedup_by(|a, b| a.path == b.path && a.requires_cwd_match == b.requires_cwd_match);
|
|
525
555
|
Ok(out)
|
|
526
556
|
}
|
|
527
557
|
|
|
558
|
+
fn collect_optional_candidate_files(
|
|
559
|
+
dir: &Path,
|
|
560
|
+
agent_id: &str,
|
|
561
|
+
out: &mut Vec<SessionCandidate>,
|
|
562
|
+
) -> Result<(), ProviderError> {
|
|
563
|
+
if dir.exists() {
|
|
564
|
+
let _ = collect_candidate_files(dir, agent_id, 0, true, out);
|
|
565
|
+
}
|
|
566
|
+
Ok(())
|
|
567
|
+
}
|
|
568
|
+
|
|
528
569
|
fn collect_candidate_files(
|
|
529
570
|
dir: &Path,
|
|
530
571
|
agent_id: &str,
|
|
531
572
|
depth: usize,
|
|
532
|
-
|
|
573
|
+
requires_cwd_match: bool,
|
|
574
|
+
out: &mut Vec<SessionCandidate>,
|
|
533
575
|
) -> Result<(), ProviderError> {
|
|
534
576
|
if depth > 4 {
|
|
535
577
|
return Ok(());
|
|
@@ -545,9 +587,12 @@ fn collect_candidate_files(
|
|
|
545
587
|
};
|
|
546
588
|
let path = entry.path();
|
|
547
589
|
if path.is_dir() {
|
|
548
|
-
collect_candidate_files(&path, agent_id, depth.saturating_add(1), out)?;
|
|
590
|
+
collect_candidate_files(&path, agent_id, depth.saturating_add(1), requires_cwd_match, out)?;
|
|
549
591
|
} else if looks_like_session_file(&path, agent_id) {
|
|
550
|
-
out.push(
|
|
592
|
+
out.push(SessionCandidate {
|
|
593
|
+
path,
|
|
594
|
+
requires_cwd_match,
|
|
595
|
+
});
|
|
551
596
|
}
|
|
552
597
|
}
|
|
553
598
|
Ok(())
|
|
@@ -572,6 +617,46 @@ fn looks_like_session_file(path: &Path, agent_id: &str) -> bool {
|
|
|
572
617
|
|| (!agent_id.is_empty() && name.contains(agent_id))
|
|
573
618
|
}
|
|
574
619
|
|
|
620
|
+
fn parse_session_records(text: &str) -> Vec<serde_json::Value> {
|
|
621
|
+
match serde_json::from_str::<serde_json::Value>(text) {
|
|
622
|
+
Ok(serde_json::Value::Array(items)) => items,
|
|
623
|
+
Ok(value) => vec![value],
|
|
624
|
+
Err(_) => parse_jsonl_records(text),
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fn provider_home_records_match_spawn_cwd(records: &[serde_json::Value], spawn_cwd: &Path) -> bool {
|
|
629
|
+
let cwd_values: Vec<String> = records.iter().filter_map(record_cwd).collect();
|
|
630
|
+
!cwd_values.is_empty()
|
|
631
|
+
&& cwd_values
|
|
632
|
+
.iter()
|
|
633
|
+
.any(|cwd| paths_equivalent(Path::new(cwd), spawn_cwd))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fn record_cwd(record: &serde_json::Value) -> Option<String> {
|
|
637
|
+
record
|
|
638
|
+
.get("cwd")
|
|
639
|
+
.and_then(serde_json::Value::as_str)
|
|
640
|
+
.or_else(|| {
|
|
641
|
+
record
|
|
642
|
+
.get("session_meta")
|
|
643
|
+
.and_then(|v| v.get("payload"))
|
|
644
|
+
.or_else(|| record.get("payload"))
|
|
645
|
+
.and_then(|v| v.get("cwd"))
|
|
646
|
+
.and_then(serde_json::Value::as_str)
|
|
647
|
+
})
|
|
648
|
+
.map(ToString::to_string)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
fn paths_equivalent(left: &Path, right: &Path) -> bool {
|
|
652
|
+
if left == right {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
|
|
656
|
+
let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
|
|
657
|
+
left == right || left.starts_with(&right)
|
|
658
|
+
}
|
|
659
|
+
|
|
575
660
|
/// `true` iff any path component is `.team` (the Team Agent runtime/logs root) — used
|
|
576
661
|
/// to gate session-file detection so `<workspace>/.team/logs/events.jsonl`,
|
|
577
662
|
/// `.team/runtime/team.db`, etc. are NEVER mistaken for a provider transcript.
|
|
@@ -790,7 +875,7 @@ fn current_team_agent_command() -> String {
|
|
|
790
875
|
}
|
|
791
876
|
|
|
792
877
|
fn has_cwd_field(record: &serde_json::Value) -> bool {
|
|
793
|
-
record
|
|
878
|
+
record_cwd(record).is_some()
|
|
794
879
|
}
|
|
795
880
|
|
|
796
881
|
fn next_session_token() -> String {
|
|
@@ -22,9 +22,18 @@ pub(crate) fn find_session_id(record: &serde_json::Value) -> Option<String> {
|
|
|
22
22
|
if let Some(s) = record.get("sessionId").and_then(serde_json::Value::as_str) {
|
|
23
23
|
return Some(s.to_string());
|
|
24
24
|
}
|
|
25
|
-
record
|
|
25
|
+
if let Some(s) = record
|
|
26
26
|
.get("session_id")
|
|
27
27
|
.and_then(serde_json::Value::as_str)
|
|
28
|
+
{
|
|
29
|
+
return Some(s.to_string());
|
|
30
|
+
}
|
|
31
|
+
record
|
|
32
|
+
.get("session_meta")
|
|
33
|
+
.and_then(|v| v.get("payload"))
|
|
34
|
+
.or_else(|| record.get("payload"))
|
|
35
|
+
.and_then(|v| v.get("id"))
|
|
36
|
+
.and_then(serde_json::Value::as_str)
|
|
28
37
|
.map(ToString::to_string)
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
//! (`identity::migrate_state_identity`,补 leader_session_uuid)→ `_migrate_active_team_key`
|
|
21
21
|
//! (seed active 指针);任一改动 → `save_runtime_state` 回写。不存在且命中缓存 → 返回缓存 deepcopy。
|
|
22
22
|
|
|
23
|
-
use std::collections::HashMap;
|
|
23
|
+
use std::collections::{BTreeSet, HashMap};
|
|
24
24
|
use std::io;
|
|
25
25
|
use std::path::{Path, PathBuf};
|
|
26
26
|
use std::sync::{LazyLock, Mutex};
|
|
@@ -170,6 +170,14 @@ impl Drop for RuntimeLock {
|
|
|
170
170
|
/// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
|
|
171
171
|
/// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
|
|
172
172
|
pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
|
|
173
|
+
save_runtime_state_with_deleted_agents(workspace, state, &[])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
177
|
+
workspace: &Path,
|
|
178
|
+
state: &Value,
|
|
179
|
+
deleted_agent_ids: &[&str],
|
|
180
|
+
) -> Result<(), StateError> {
|
|
173
181
|
let path = runtime_state_path(workspace);
|
|
174
182
|
if cache_equals(&path, state) {
|
|
175
183
|
return Ok(());
|
|
@@ -203,6 +211,15 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
|
|
|
203
211
|
if let Some(parent) = path.parent() {
|
|
204
212
|
std::fs::create_dir_all(parent)?;
|
|
205
213
|
}
|
|
214
|
+
if let Some(latest) = read_latest_state_under_lock(workspace, &path) {
|
|
215
|
+
let deleted = deleted_agent_ids
|
|
216
|
+
.iter()
|
|
217
|
+
.copied()
|
|
218
|
+
.filter(|id| !id.is_empty())
|
|
219
|
+
.map(str::to_string)
|
|
220
|
+
.collect::<BTreeSet<_>>();
|
|
221
|
+
preserve_latest_roster_entries(&mut migrated, &latest, &deleted);
|
|
222
|
+
}
|
|
206
223
|
// 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
|
|
207
224
|
let payload = serde_json::to_string_pretty(&migrated)?;
|
|
208
225
|
let delays = [0.05_f64, 0.2, 0.5];
|
|
@@ -243,6 +260,101 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
|
|
|
243
260
|
Err(StateError::SaveFailed("retry loop exhausted without return".to_string()))
|
|
244
261
|
}
|
|
245
262
|
|
|
263
|
+
fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value> {
|
|
264
|
+
let text = std::fs::read_to_string(path).ok()?;
|
|
265
|
+
let mut latest = serde_json::from_str::<Value>(&text).ok()?;
|
|
266
|
+
normalize_agent_session_state(&mut latest);
|
|
267
|
+
let _ = migrate_state_identity(&mut latest, &SystemEnv, workspace);
|
|
268
|
+
let _ = migrate_active_team_key(&mut latest);
|
|
269
|
+
Some(latest)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_agent_ids: &BTreeSet<String>) {
|
|
273
|
+
if !same_runtime_projection(incoming, latest) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
|
|
277
|
+
|
|
278
|
+
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
279
|
+
if let Some(active_team) = active_team.as_deref() {
|
|
280
|
+
let latest_active_agents = latest
|
|
281
|
+
.get("teams")
|
|
282
|
+
.and_then(Value::as_object)
|
|
283
|
+
.and_then(|teams| teams.get(active_team))
|
|
284
|
+
.and_then(|entry| entry.get("agents"));
|
|
285
|
+
preserve_missing_agents(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let latest_teams = latest.get("teams").and_then(Value::as_object);
|
|
289
|
+
let Some(incoming_teams) = incoming.get_mut("teams").and_then(Value::as_object_mut) else {
|
|
290
|
+
return;
|
|
291
|
+
};
|
|
292
|
+
if let Some(latest_teams) = latest_teams {
|
|
293
|
+
for (team, latest_entry) in latest_teams {
|
|
294
|
+
let Some(incoming_entry) = incoming_teams.get_mut(team) else {
|
|
295
|
+
continue;
|
|
296
|
+
};
|
|
297
|
+
preserve_missing_agents(
|
|
298
|
+
incoming_entry.get_mut("agents"),
|
|
299
|
+
latest_entry.get("agents"),
|
|
300
|
+
deleted_agent_ids,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if let Some(active_team) = active_team.as_deref() {
|
|
305
|
+
let latest_top_agents = latest.get("agents");
|
|
306
|
+
if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
|
|
307
|
+
preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fn preserve_missing_agents(
|
|
313
|
+
incoming_agents: Option<&mut Value>,
|
|
314
|
+
latest_agents: Option<&Value>,
|
|
315
|
+
deleted_agent_ids: &BTreeSet<String>,
|
|
316
|
+
) {
|
|
317
|
+
let Some(incoming_agents) = incoming_agents else {
|
|
318
|
+
return;
|
|
319
|
+
};
|
|
320
|
+
let Some(incoming_map) = incoming_agents.as_object_mut() else {
|
|
321
|
+
return;
|
|
322
|
+
};
|
|
323
|
+
let Some(latest_map) = latest_agents.and_then(Value::as_object) else {
|
|
324
|
+
return;
|
|
325
|
+
};
|
|
326
|
+
for (agent_id, latest_agent) in latest_map {
|
|
327
|
+
if deleted_agent_ids.contains(agent_id) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
incoming_map
|
|
331
|
+
.entry(agent_id.clone())
|
|
332
|
+
.or_insert_with(|| latest_agent.clone());
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
fn same_runtime_projection(left: &Value, right: &Value) -> bool {
|
|
337
|
+
let left_session = left.get("session_name").and_then(Value::as_str);
|
|
338
|
+
let right_session = right.get("session_name").and_then(Value::as_str);
|
|
339
|
+
if left_session.is_some() && right_session.is_some() && left_session != right_session {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
let left_team = active_team_key(left);
|
|
343
|
+
let right_team = active_team_key(right);
|
|
344
|
+
if left_team.is_some() && right_team.is_some() && left_team != right_team {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
fn active_team_key(state: &Value) -> Option<String> {
|
|
351
|
+
state
|
|
352
|
+
.get("active_team_key")
|
|
353
|
+
.and_then(Value::as_str)
|
|
354
|
+
.filter(|team| !team.is_empty() && *team != "current")
|
|
355
|
+
.map(str::to_string)
|
|
356
|
+
}
|
|
357
|
+
|
|
246
358
|
/// `_self_heal_runtime_state`:重建 inode(heal-tmp + backup-rename),绝不 in-place truncate。
|
|
247
359
|
fn self_heal(
|
|
248
360
|
workspace: &Path,
|