@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.
@@ -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 spec_path = paths.spec_workspace.join("team.spec.yaml");
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 mut stmt = conn.prepare(
35
- "select result_id, task_id, agent_id, envelope, status, created_at
36
- from results
37
- where status not in ('collected', 'invalid')
38
- order by created_at, result_id",
39
- )?;
40
- let rows = stmt
41
- .query_map([], |row| {
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
- conn.execute(
99
- "update results set status = 'collected' where result_id = ?1",
100
- params![row.result_id.as_str()],
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
- crate::state::persist::save_runtime_state(&paths.run_workspace, &state)?;
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": paths.spec_workspace.join("team_state.md").to_string_lossy().to_string(),
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(conn: &rusqlite::Connection, path: &Path) -> Result<(), MessagingError> {
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
- None,
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 = conn.query_row(
281
- "select count(*) from messages where message_id = ?1 and recipient = ?2",
282
- params![task_id, agent_id],
283
- |row| row.get(0),
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(conn: &rusqlite::Connection) -> Result<serde_json::Value, MessagingError> {
289
- let total: i64 = conn.query_row("select count(*) from results", [], |row| row.get(0))?;
290
- let collected: i64 = conn.query_row(
291
- "select count(*) from results where status = 'collected'",
292
- [],
293
- |row| row.get(0),
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 mut stmt = conn.prepare(
303
- "select status, count(*) from results
304
- where status not in ('collected', 'invalid')
305
- group by status
306
- order by status",
307
- )?;
308
- let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?;
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 path in candidates {
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 = parse_jsonl_records(&text);
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
- fn candidate_session_files(spawn_cwd: &Path, agent_id: &str) -> Result<Vec<PathBuf>, ProviderError> {
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
- out.sort_by(|a, b| a.to_string_lossy().cmp(&b.to_string_lossy()));
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
- out: &mut Vec<PathBuf>,
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(path);
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.get("cwd").and_then(serde_json::Value::as_str).is_some()
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,