@team-agent/installer 0.4.11 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +98 -5
  6. package/crates/team-agent/src/cli/mod.rs +2 -0
  7. package/crates/team-agent/src/cli/named_address.rs +864 -0
  8. package/crates/team-agent/src/cli/send.rs +104 -0
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
  12. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  13. package/crates/team-agent/src/cli/types.rs +4 -0
  14. package/crates/team-agent/src/compiler.rs +11 -20
  15. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  16. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  17. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  19. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  20. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  21. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  22. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  23. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  24. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  25. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  26. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  27. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  28. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  29. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  34. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  35. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  38. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  41. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
  42. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  43. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  44. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  45. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  46. package/crates/team-agent/src/messaging/activity.rs +43 -18
  47. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  48. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  49. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  50. package/crates/team-agent/src/messaging/results.rs +34 -9
  51. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  52. package/crates/team-agent/src/model/spec.rs +10 -6
  53. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  54. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  55. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  56. package/crates/team-agent/src/provider/classify.rs +23 -48
  57. package/crates/team-agent/src/provider/command.rs +16 -7
  58. package/crates/team-agent/src/provider/faults.rs +93 -53
  59. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  60. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  61. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  62. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  63. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  64. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  65. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  66. package/crates/team-agent/src/provider/types.rs +33 -1
  67. package/crates/team-agent/src/provider/wire.rs +171 -1
  68. package/crates/team-agent/src/state/identity.rs +242 -57
  69. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  70. package/crates/team-agent/src/state/mod.rs +5 -1
  71. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  72. package/crates/team-agent/src/state/ownership.rs +12 -11
  73. package/crates/team-agent/src/state/paths.rs +13 -6
  74. package/crates/team-agent/src/state/persist.rs +671 -128
  75. package/crates/team-agent/src/state/projection.rs +240 -49
  76. package/crates/team-agent/src/state/selector.rs +11 -5
  77. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  78. package/crates/team-agent/src/tmux_backend.rs +42 -5
  79. package/crates/team-agent/src/transport/test_support.rs +55 -6
  80. package/package.json +4 -4
@@ -23,8 +23,8 @@
23
23
  use std::collections::{BTreeSet, HashMap};
24
24
  use std::io;
25
25
  use std::path::{Path, PathBuf};
26
- use std::sync::{LazyLock, Mutex};
27
26
  use std::sync::atomic::{AtomicU64, Ordering};
27
+ use std::sync::{LazyLock, Mutex};
28
28
  use std::time::{Duration, Instant};
29
29
 
30
30
  use serde_json::{json, Value};
@@ -45,6 +45,25 @@ const SESSION_STATE_FIELDS: [&str; 6] = [
45
45
  "attribution_confidence",
46
46
  "spawn_cwd",
47
47
  ];
48
+ const LIVE_TOPOLOGY_FIELDS: [&str; 5] =
49
+ ["pane_id", "pane_pid", "window", "spawned_at", "spawn_epoch"];
50
+ const ROSTER_STUB_ALLOWLIST: [&str; 15] = [
51
+ "agent_id",
52
+ "provider",
53
+ "auth_mode",
54
+ "role",
55
+ "model",
56
+ "model_source",
57
+ "profile",
58
+ "_profile_dir",
59
+ "dynamic_role_file",
60
+ "effort",
61
+ "forked_from",
62
+ "managed_mcp_config",
63
+ "claude_config_dir",
64
+ "claude_projects_root",
65
+ "profile_launch",
66
+ ];
48
67
 
49
68
  /// `state.py:_RUNTIME_STATE_CACHE`:进程级 path→state 缓存(deep-equal 早返回)。
50
69
  static RUNTIME_STATE_CACHE: LazyLock<Mutex<HashMap<PathBuf, Value>>> =
@@ -57,7 +76,9 @@ pub fn runtime_state_path(workspace: &Path) -> PathBuf {
57
76
  }
58
77
 
59
78
  fn cache_equals(path: &Path, state: &Value) -> bool {
60
- RUNTIME_STATE_CACHE.lock().is_ok_and(|c| c.get(path) == Some(state))
79
+ RUNTIME_STATE_CACHE
80
+ .lock()
81
+ .is_ok_and(|c| c.get(path) == Some(state))
61
82
  }
62
83
  fn cache_set(path: &Path, state: &Value) {
63
84
  if let Ok(mut c) = RUNTIME_STATE_CACHE.lock() {
@@ -66,11 +87,16 @@ fn cache_set(path: &Path, state: &Value) {
66
87
  }
67
88
  /// `_RUNTIME_STATE_CACHE.get(...)` → `copy.deepcopy(cached)`(clone = deepcopy)。
68
89
  fn cache_get(path: &Path) -> Option<Value> {
69
- RUNTIME_STATE_CACHE.lock().ok().and_then(|c| c.get(path).cloned())
90
+ RUNTIME_STATE_CACHE
91
+ .lock()
92
+ .ok()
93
+ .and_then(|c| c.get(path).cloned())
70
94
  }
71
95
 
72
96
  fn unique_tmp(path: &Path, suffix: &str) -> PathBuf {
73
- let name = path.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
97
+ let name = path
98
+ .file_name()
99
+ .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
74
100
  let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
75
101
  path.with_file_name(format!("{name}.{}.{seq}.{suffix}", std::process::id()))
76
102
  }
@@ -132,7 +158,11 @@ impl RuntimeLock {
132
158
  if let Some(parent) = lock_path.parent() {
133
159
  std::fs::create_dir_all(parent)?;
134
160
  }
135
- let file = std::fs::OpenOptions::new().create(true).write(true).truncate(false).open(&lock_path)?;
161
+ let file = std::fs::OpenOptions::new()
162
+ .create(true)
163
+ .write(true)
164
+ .truncate(false)
165
+ .open(&lock_path)?;
136
166
  #[cfg(unix)]
137
167
  {
138
168
  use std::os::unix::io::AsRawFd;
@@ -153,7 +183,9 @@ impl RuntimeLock {
153
183
  #[cfg(not(unix))]
154
184
  {
155
185
  let _ = timeout;
156
- Err(StateError::Locked(format!("{name} (runtime lock not yet implemented on non-unix)")))
186
+ Err(StateError::Locked(format!(
187
+ "{name} (runtime lock not yet implemented on non-unix)"
188
+ )))
157
189
  }
158
190
  }
159
191
  }
@@ -170,7 +202,34 @@ impl Drop for RuntimeLock {
170
202
  /// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
171
203
  /// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
172
204
  pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
173
- save_runtime_state_with_merge_exceptions(workspace, state, &[], None, &[])
205
+ save_runtime_state_with_merge_options(workspace, state, &[], None, &[], &[])
206
+ }
207
+
208
+ pub(crate) fn save_runtime_state_reapplying_after_conflict<F>(
209
+ workspace: &Path,
210
+ state: &Value,
211
+ reapply: F,
212
+ ) -> Result<(), StateError>
213
+ where
214
+ F: FnOnce(&mut Value),
215
+ {
216
+ match save_runtime_state(workspace, state) {
217
+ Ok(()) => Ok(()),
218
+ Err(StateError::SaveConflict(_)) => {
219
+ let mut latest = load_runtime_state(workspace)?;
220
+ reapply(&mut latest);
221
+ save_runtime_state(workspace, &latest)
222
+ }
223
+ Err(error) => Err(error),
224
+ }
225
+ }
226
+
227
+ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
228
+ workspace: &Path,
229
+ state: &Value,
230
+ agent_ids: &[&str],
231
+ ) -> Result<(), StateError> {
232
+ save_runtime_state_with_merge_options(workspace, state, &[], None, &[], agent_ids)
174
233
  }
175
234
 
176
235
  pub(crate) fn save_runtime_state_with_deleted_agents(
@@ -178,7 +237,7 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
178
237
  state: &Value,
179
238
  deleted_agent_ids: &[&str],
180
239
  ) -> Result<(), StateError> {
181
- save_runtime_state_with_merge_exceptions(workspace, state, deleted_agent_ids, None, &[])
240
+ save_runtime_state_with_merge_options(workspace, state, deleted_agent_ids, None, &[], &[])
182
241
  }
183
242
 
184
243
  pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
@@ -187,21 +246,39 @@ pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
187
246
  tombstoned_team_key: &str,
188
247
  tombstoned_agent_ids: &[&str],
189
248
  ) -> Result<(), StateError> {
190
- save_runtime_state_with_merge_exceptions(
249
+ save_runtime_state_with_merge_options(
191
250
  workspace,
192
251
  state,
193
252
  &[],
194
253
  Some(tombstoned_team_key),
195
254
  tombstoned_agent_ids,
255
+ &[],
256
+ )
257
+ }
258
+
259
+ pub(crate) fn save_runtime_state_with_team_tombstone_lifecycle_topology_authority(
260
+ workspace: &Path,
261
+ state: &Value,
262
+ tombstoned_team_key: &str,
263
+ agent_ids: &[&str],
264
+ ) -> Result<(), StateError> {
265
+ save_runtime_state_with_merge_options(
266
+ workspace,
267
+ state,
268
+ &[],
269
+ Some(tombstoned_team_key),
270
+ agent_ids,
271
+ agent_ids,
196
272
  )
197
273
  }
198
274
 
199
- fn save_runtime_state_with_merge_exceptions(
275
+ fn save_runtime_state_with_merge_options(
200
276
  workspace: &Path,
201
277
  state: &Value,
202
278
  deleted_agent_ids: &[&str],
203
279
  skip_capture_backfill_team_key: Option<&str>,
204
280
  skip_capture_backfill_agent_ids: &[&str],
281
+ topology_update_agent_ids: &[&str],
205
282
  ) -> Result<(), StateError> {
206
283
  let path = runtime_state_path(workspace);
207
284
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
@@ -265,13 +342,20 @@ fn save_runtime_state_with_merge_exceptions(
265
342
  .filter(|id| !id.is_empty())
266
343
  .map(str::to_string)
267
344
  .collect::<BTreeSet<_>>();
268
- preserve_latest_roster_entries(
345
+ let topology_updates = topology_update_agent_ids
346
+ .iter()
347
+ .copied()
348
+ .filter(|id| !id.is_empty())
349
+ .map(str::to_string)
350
+ .collect::<BTreeSet<_>>();
351
+ apply_persist_merge_contract(
269
352
  &mut migrated,
270
353
  &latest,
271
354
  &deleted,
272
355
  skip_capture_backfill_team_key,
273
356
  &skip_capture_backfill,
274
- );
357
+ &topology_updates,
358
+ )?;
275
359
  }
276
360
  // Stage 3 save-output strip second pass (defence-in-depth): after the
277
361
  // `preserve_latest_roster_entries` lock-held merge, a future addition
@@ -316,7 +400,9 @@ fn save_runtime_state_with_merge_exceptions(
316
400
  }
317
401
  }
318
402
  }
319
- Err(StateError::SaveFailed("retry loop exhausted without return".to_string()))
403
+ Err(StateError::SaveFailed(
404
+ "retry loop exhausted without return".to_string(),
405
+ ))
320
406
  }
321
407
 
322
408
  fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value> {
@@ -328,31 +414,33 @@ fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value>
328
414
  Some(latest)
329
415
  }
330
416
 
331
- fn preserve_latest_roster_entries(
417
+ fn apply_persist_merge_contract(
332
418
  incoming: &mut Value,
333
419
  latest: &Value,
334
420
  deleted_agent_ids: &BTreeSet<String>,
335
421
  skip_capture_backfill_team_key: Option<&str>,
336
422
  skip_capture_backfill_agent_ids: &BTreeSet<String>,
337
- ) {
423
+ topology_update_agent_ids: &BTreeSet<String>,
424
+ ) -> Result<(), StateError> {
338
425
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
339
426
  // the top-level<->active-team cross projections depend on which team is active); the
340
427
  // per-team `teams.<k>.agents` merge below is team-key self-identifying and must run
341
428
  // even when another process flipped session_name/active_team_key between this
342
429
  // writer's load and save.
343
430
  let projection_matches = same_runtime_projection(incoming, latest);
344
- let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
345
431
  let top_level_team = Some(team_state_key(incoming)).or_else(|| Some(team_state_key(latest)));
346
432
  let skip_top_level_capture_backfill =
347
433
  should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
348
434
  if projection_matches {
349
- preserve_missing_agents(
435
+ merge_agent_projection(
436
+ "agents",
350
437
  incoming.get_mut("agents"),
351
438
  latest.get("agents"),
352
439
  deleted_agent_ids,
353
440
  skip_top_level_capture_backfill,
354
441
  skip_capture_backfill_agent_ids,
355
- );
442
+ topology_update_agent_ids,
443
+ )?;
356
444
  // Stage 3c (identity-boundary unified plan, architect direction
357
445
  // 2026-06-23): top-level owner copy-back removed. Pre-3c this
358
446
  // copied legacy `state.{team_owner, leader_receiver, owner_epoch}`
@@ -365,67 +453,35 @@ fn preserve_latest_roster_entries(
365
453
  // them.
366
454
  }
367
455
 
368
- if projection_matches {
369
- if let Some(active_team) = active_team.as_deref() {
370
- let latest_active_agents = latest
371
- .get("teams")
372
- .and_then(Value::as_object)
373
- .and_then(|teams| teams.get(active_team))
374
- .and_then(|entry| entry.get("agents"));
375
- preserve_missing_agents(
376
- incoming.get_mut("agents"),
377
- latest_active_agents,
378
- deleted_agent_ids,
379
- skip_top_level_capture_backfill,
380
- skip_capture_backfill_agent_ids,
381
- );
382
- }
383
- }
384
-
385
456
  let latest_teams = latest.get("teams").and_then(Value::as_object);
386
457
  let Some(incoming_teams) = incoming.get_mut("teams").and_then(Value::as_object_mut) else {
387
- return;
458
+ return Ok(());
388
459
  };
389
460
  if let Some(latest_teams) = latest_teams {
390
461
  for (team, latest_entry) in latest_teams {
391
462
  let Some(incoming_entry) = incoming_teams.get_mut(team) else {
392
463
  continue;
393
464
  };
394
- preserve_missing_agents(
465
+ let projection = format!("teams.{team}.agents");
466
+ merge_agent_projection(
467
+ &projection,
395
468
  incoming_entry.get_mut("agents"),
396
469
  latest_entry.get("agents"),
397
470
  deleted_agent_ids,
398
471
  should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
399
472
  skip_capture_backfill_agent_ids,
400
- );
473
+ topology_update_agent_ids,
474
+ )?;
401
475
  preserve_latest_ownership_fields(incoming_entry, latest_entry);
402
476
  }
403
477
  }
404
- if projection_matches {
405
- if let Some(active_team) = active_team.as_deref() {
406
- let latest_top_agents = latest.get("agents");
407
- if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
408
- preserve_missing_agents(
409
- incoming_entry.get_mut("agents"),
410
- latest_top_agents,
411
- deleted_agent_ids,
412
- should_skip_capture_backfill(Some(active_team), skip_capture_backfill_team_key),
413
- skip_capture_backfill_agent_ids,
414
- );
415
- // Stage 3c (identity-boundary unified plan, architect direction
416
- // 2026-06-23): top-level → teams.<active> owner cross-promote
417
- // removed. This was the persist-side mirror of the projection
418
- // copy-back (3b) and a quiet migration path that let stale
419
- // top-level owner re-seed teams.<active>. With write_owner
420
- // (3a) producing both top-level and teams.<key> directly,
421
- // this auto-promotion is redundant and reintroduces the dual
422
- // source the unified plan eliminates.
423
- }
424
- }
425
- }
478
+ Ok(())
426
479
  }
427
480
 
428
- fn should_skip_capture_backfill(current_team_key: Option<&str>, skip_team_key: Option<&str>) -> bool {
481
+ fn should_skip_capture_backfill(
482
+ current_team_key: Option<&str>,
483
+ skip_team_key: Option<&str>,
484
+ ) -> bool {
429
485
  match skip_team_key {
430
486
  Some(skip_team_key) => current_team_key == Some(skip_team_key),
431
487
  None => true,
@@ -452,9 +508,7 @@ fn latest_has_preferable_ownership(incoming: &Value, latest: &Value) -> bool {
452
508
  if latest_epoch > incoming_epoch {
453
509
  return true;
454
510
  }
455
- latest_epoch == incoming_epoch
456
- && !ownership_attached(incoming)
457
- && ownership_attached(latest)
511
+ latest_epoch == incoming_epoch && !ownership_attached(incoming) && ownership_attached(latest)
458
512
  }
459
513
 
460
514
  fn ownership_epoch(state: &Value) -> u64 {
@@ -486,37 +540,103 @@ fn ownership_attached(state: &Value) -> bool {
486
540
  })
487
541
  }
488
542
 
489
- fn preserve_missing_agents(
543
+ fn merge_agent_projection(
544
+ projection: &str,
490
545
  incoming_agents: Option<&mut Value>,
491
546
  latest_agents: Option<&Value>,
492
547
  deleted_agent_ids: &BTreeSet<String>,
493
548
  skip_capture_backfill: bool,
494
549
  skip_capture_backfill_agent_ids: &BTreeSet<String>,
495
- ) {
550
+ topology_update_agent_ids: &BTreeSet<String>,
551
+ ) -> Result<(), StateError> {
496
552
  let Some(incoming_agents) = incoming_agents else {
497
- return;
553
+ return Ok(());
498
554
  };
499
555
  let Some(incoming_map) = incoming_agents.as_object_mut() else {
500
- return;
556
+ return Ok(());
501
557
  };
502
558
  let Some(latest_map) = latest_agents.and_then(Value::as_object) else {
503
- return;
559
+ return Ok(());
504
560
  };
505
561
  for (agent_id, latest_agent) in latest_map {
506
562
  if deleted_agent_ids.contains(agent_id) {
507
563
  continue;
508
564
  }
565
+ let tombstoned_for_projection =
566
+ skip_capture_backfill && skip_capture_backfill_agent_ids.contains(agent_id);
509
567
  match incoming_map.entry(agent_id.clone()) {
510
568
  serde_json::map::Entry::Vacant(slot) => {
511
- slot.insert(latest_agent.clone());
569
+ if topology_update_agent_ids.contains(agent_id) {
570
+ continue;
571
+ }
572
+ if !tombstoned_for_projection && latest_has_live_topology(latest_agent) {
573
+ return Err(save_conflict(
574
+ projection,
575
+ agent_id,
576
+ live_topology_fields(latest_agent),
577
+ ));
578
+ }
579
+ if let Some(stub) = roster_stub(latest_agent) {
580
+ slot.insert(stub);
581
+ }
512
582
  }
513
583
  serde_json::map::Entry::Occupied(mut existing) => {
584
+ if !tombstoned_for_projection && !topology_update_agent_ids.contains(agent_id) {
585
+ let fields = topology_conflict_fields(existing.get(), latest_agent);
586
+ if !fields.is_empty() {
587
+ return Err(save_conflict(projection, agent_id, fields));
588
+ }
589
+ }
514
590
  if !skip_capture_backfill || !skip_capture_backfill_agent_ids.contains(agent_id) {
515
591
  backfill_capture_fields(existing.get_mut(), latest_agent);
516
592
  }
517
593
  }
518
594
  }
519
595
  }
596
+ Ok(())
597
+ }
598
+
599
+ fn save_conflict(projection: &str, agent_id: &str, fields: Vec<&'static str>) -> StateError {
600
+ StateError::SaveConflict(format!(
601
+ "agent_id={agent_id} projection={projection} conflicting_fields={}",
602
+ fields.join(",")
603
+ ))
604
+ }
605
+
606
+ fn latest_has_live_topology(agent: &Value) -> bool {
607
+ LIVE_TOPOLOGY_FIELDS
608
+ .iter()
609
+ .any(|field| agent.get(field).is_some_and(json_truthy))
610
+ }
611
+
612
+ fn live_topology_fields(agent: &Value) -> Vec<&'static str> {
613
+ LIVE_TOPOLOGY_FIELDS
614
+ .iter()
615
+ .copied()
616
+ .filter(|field| agent.get(*field).is_some_and(json_truthy))
617
+ .collect()
618
+ }
619
+
620
+ fn topology_conflict_fields(incoming_agent: &Value, latest_agent: &Value) -> Vec<&'static str> {
621
+ let latest_live = live_topology_fields(latest_agent);
622
+ if latest_live.is_empty() {
623
+ return Vec::new();
624
+ }
625
+ latest_live
626
+ .into_iter()
627
+ .filter(|field| incoming_agent.get(*field) != latest_agent.get(*field))
628
+ .collect()
629
+ }
630
+
631
+ fn roster_stub(latest_agent: &Value) -> Option<Value> {
632
+ let latest = latest_agent.as_object()?;
633
+ let mut stub = serde_json::Map::new();
634
+ for field in ROSTER_STUB_ALLOWLIST {
635
+ if let Some(value) = latest.get(field).filter(|value| !value.is_null()) {
636
+ stub.insert(field.to_string(), value.clone());
637
+ }
638
+ }
639
+ (!stub.is_empty()).then(|| Value::Object(stub))
520
640
  }
521
641
 
522
642
  /// 0.4.6 tuple-atomic backfill (restart-persist-capture-contract-audit.md):
@@ -544,12 +664,7 @@ fn preserve_missing_agents(
544
664
  /// 4. `attribution_confidence` rides with the tuple (copied iff tuple
545
665
  /// backfill fires).
546
666
  fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
547
- const TUPLE_FIELDS: [&str; 4] = [
548
- "session_id",
549
- "rollout_path",
550
- "captured_at",
551
- "captured_via",
552
- ];
667
+ const TUPLE_FIELDS: [&str; 4] = ["session_id", "rollout_path", "captured_at", "captured_via"];
553
668
  let Some(incoming_row) = incoming_agent.as_object_mut() else {
554
669
  return;
555
670
  };
@@ -633,7 +748,9 @@ fn self_heal(
633
748
  ) -> Result<(), StateError> {
634
749
  let event_log = EventLog::new(workspace);
635
750
  let heal_tmp = unique_tmp(path, "heal.tmp");
636
- let name = path.file_name().map_or_else(String::new, |n| n.to_string_lossy().into_owned());
751
+ let name = path
752
+ .file_name()
753
+ .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
637
754
  let backup = path.with_file_name(format!("{name}.bak.{}", std::process::id()));
638
755
  let mut backup_created = false;
639
756
 
@@ -706,11 +823,17 @@ pub fn normalize_agent_session_state(state: &mut Value) {
706
823
  /// 注:Python `seed if seed in teams or not teams else seed` 两支均为 `seed`(死三元),
707
824
  /// 此处直接 `= seed`,与可观测行为一致。
708
825
  pub fn migrate_active_team_key(state: &mut Value) -> bool {
709
- if state.as_object().is_some_and(|o| o.contains_key("active_team_key")) {
826
+ if state
827
+ .as_object()
828
+ .is_some_and(|o| o.contains_key("active_team_key"))
829
+ {
710
830
  return false;
711
831
  }
712
832
  let teams_is_dict = state.get("teams").is_some_and(Value::is_object);
713
- let teams_len = state.get("teams").and_then(Value::as_object).map_or(0, serde_json::Map::len);
833
+ let teams_len = state
834
+ .get("teams")
835
+ .and_then(Value::as_object)
836
+ .map_or(0, serde_json::Map::len);
714
837
  if state.get("session_name").is_some_and(json_truthy) {
715
838
  let seed = team_state_key(state);
716
839
  if let Some(o) = state.as_object_mut() {
@@ -719,7 +842,10 @@ pub fn migrate_active_team_key(state: &mut Value) -> bool {
719
842
  return true;
720
843
  }
721
844
  if teams_is_dict && teams_len == 1 {
722
- let first = state.get("teams").and_then(Value::as_object).and_then(|t| t.keys().next().cloned());
845
+ let first = state
846
+ .get("teams")
847
+ .and_then(Value::as_object)
848
+ .and_then(|t| t.keys().next().cloned());
723
849
  if let (Some(k), Some(o)) = (first, state.as_object_mut()) {
724
850
  o.insert("active_team_key".to_string(), Value::String(k));
725
851
  }
@@ -803,7 +929,9 @@ pub fn load_runtime_state(workspace: &Path) -> Result<Value, StateError> {
803
929
  if let Some(cached) = cache_get(&path) {
804
930
  return Ok(cached);
805
931
  }
806
- return Ok(json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null}));
932
+ return Ok(
933
+ json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null}),
934
+ );
807
935
  }
808
936
  let text = std::fs::read_to_string(&path)?;
809
937
  let mut state: Value = serde_json::from_str(&text)?;
@@ -843,7 +971,10 @@ mod tests {
843
971
  EventLog::new(ws).tail(50).unwrap()
844
972
  }
845
973
  fn count_event(ws: &Path, name: &str) -> usize {
846
- read_events(ws).iter().filter(|e| e["event"] == json!(name)).count()
974
+ read_events(ws)
975
+ .iter()
976
+ .filter(|e| e["event"] == json!(name))
977
+ .count()
847
978
  }
848
979
  // per-call-index 故障计划:plan[i] = 第 i 次 atomic_replace 的 errno(0=成功)。
849
980
  fn set_fault_plan(plan: &[i32]) {
@@ -854,18 +985,32 @@ mod tests {
854
985
  set_fault_plan(&[]);
855
986
  }
856
987
  fn get_event(ws: &Path, name: &str) -> Value {
857
- read_events(ws).into_iter().find(|e| e["event"] == json!(name)).unwrap_or(Value::Null)
988
+ read_events(ws)
989
+ .into_iter()
990
+ .find(|e| e["event"] == json!(name))
991
+ .unwrap_or(Value::Null)
858
992
  }
859
993
  fn read_state(ws: &Path) -> Value {
860
994
  serde_json::from_str(&std::fs::read_to_string(runtime_state_path(ws)).unwrap()).unwrap()
861
995
  }
996
+ fn write_state(ws: &Path, state: &Value) {
997
+ std::fs::create_dir_all(runtime_dir(ws)).unwrap();
998
+ std::fs::write(
999
+ runtime_state_path(ws),
1000
+ serde_json::to_string_pretty(state).unwrap(),
1001
+ )
1002
+ .unwrap();
1003
+ }
862
1004
  fn bak_files(ws: &Path) -> Vec<PathBuf> {
863
1005
  let dir = runtime_dir(ws);
864
1006
  std::fs::read_dir(&dir)
865
1007
  .map(|rd| {
866
1008
  rd.filter_map(std::result::Result::ok)
867
1009
  .map(|e| e.path())
868
- .filter(|p| p.file_name().is_some_and(|n| n.to_string_lossy().contains(".bak.")))
1010
+ .filter(|p| {
1011
+ p.file_name()
1012
+ .is_some_and(|n| n.to_string_lossy().contains(".bak."))
1013
+ })
869
1014
  .collect()
870
1015
  })
871
1016
  .unwrap_or_default()
@@ -880,13 +1025,18 @@ mod tests {
880
1025
  ));
881
1026
  let canonical = include_str!("testdata/state-rich.canonical.json");
882
1027
  let v: Value = serde_json::from_str(fixture).unwrap();
883
- assert_eq!(serde_json::to_string_pretty(&v).unwrap(), canonical, "state.json 序列化未字节对齐 Python indent=2");
1028
+ assert_eq!(
1029
+ serde_json::to_string_pretty(&v).unwrap(),
1030
+ canonical,
1031
+ "state.json 序列化未字节对齐 Python indent=2"
1032
+ );
884
1033
  }
885
1034
 
886
1035
  #[test]
887
1036
  fn save_writes_atomically_and_caches() {
888
1037
  let ws = temp_ws();
889
- let state = json!({"session_name":"t","agents":{"a":{"agent_id":"a"}},"active_team_key":"t"});
1038
+ let state =
1039
+ json!({"session_name":"t","agents":{"a":{"agent_id":"a"}},"active_team_key":"t"});
890
1040
  save_runtime_state(&ws, &state).unwrap();
891
1041
  let on_disk = std::fs::read_to_string(runtime_state_path(&ws)).unwrap();
892
1042
  assert_eq!(on_disk, serde_json::to_string_pretty(&state).unwrap());
@@ -903,7 +1053,10 @@ mod tests {
903
1053
  // 删掉文件;若缓存早返回生效,save 相同 state 不会重建文件。
904
1054
  std::fs::remove_file(runtime_state_path(&ws)).unwrap();
905
1055
  save_runtime_state(&ws, &state).unwrap();
906
- assert!(!runtime_state_path(&ws).exists(), "deep-equal 命中缓存 → 未重写(文件仍不存在)");
1056
+ assert!(
1057
+ !runtime_state_path(&ws).exists(),
1058
+ "deep-equal 命中缓存 → 未重写(文件仍不存在)"
1059
+ );
907
1060
  }
908
1061
 
909
1062
  // bug-084 核心:EACCES 重试 3 次(有界退避)→ self-heal 成功 + 事件**字段精确**。
@@ -917,7 +1070,10 @@ mod tests {
917
1070
  clear_fault();
918
1071
  assert_eq!(read_state(&ws), s2, "inode 重建,文件为 s2");
919
1072
  // 事件序列 + 字段精确。
920
- let retries: Vec<_> = read_events(&ws).into_iter().filter(|e| e["event"] == json!("runtime.state.save_retry")).collect();
1073
+ let retries: Vec<_> = read_events(&ws)
1074
+ .into_iter()
1075
+ .filter(|e| e["event"] == json!("runtime.state.save_retry"))
1076
+ .collect();
921
1077
  assert_eq!(retries.len(), 3, "3 次重试");
922
1078
  assert_eq!(retries[0]["attempt"], json!(1));
923
1079
  assert_eq!(retries[0]["errno_name"], json!("EACCES"));
@@ -939,7 +1095,10 @@ mod tests {
939
1095
  clear_fault();
940
1096
  assert_eq!(read_state(&ws), json!({"v":2}));
941
1097
  assert_eq!(count_event(&ws, "runtime.state.self_healed"), 1);
942
- assert_eq!(get_event(&ws, "runtime.state.save_retry")["errno_name"], json!(name));
1098
+ assert_eq!(
1099
+ get_event(&ws, "runtime.state.save_retry")["errno_name"],
1100
+ json!(name)
1101
+ );
943
1102
  }
944
1103
  }
945
1104
 
@@ -964,7 +1123,11 @@ mod tests {
964
1123
  clear_fault();
965
1124
  assert_eq!(read_state(&ws), json!({"v":7}));
966
1125
  assert_eq!(count_event(&ws, "runtime.state.save_retry"), 3);
967
- assert_eq!(count_event(&ws, "runtime.state.self_healed"), 0, "未触发 self-heal");
1126
+ assert_eq!(
1127
+ count_event(&ws, "runtime.state.self_healed"),
1128
+ 0,
1129
+ "未触发 self-heal"
1130
+ );
968
1131
  }
969
1132
 
970
1133
  // 崩溃安全不变量①:self-heal 中途失败但 restore 成功 → 原 state 复位 + 0 restore_failed + 1 save_failed。
@@ -979,8 +1142,15 @@ mod tests {
979
1142
  let r = save_runtime_state(&ws, &json!({"keep":"new"}));
980
1143
  clear_fault();
981
1144
  assert!(matches!(r, Err(StateError::SaveFailed(_))));
982
- assert_eq!(read_state(&ws), original, "restore 成功:原 state 复位到 state.json");
983
- assert_eq!(count_event(&ws, "runtime.state.self_heal_restore_failed"), 0);
1145
+ assert_eq!(
1146
+ read_state(&ws),
1147
+ original,
1148
+ "restore 成功:原 state 复位到 state.json"
1149
+ );
1150
+ assert_eq!(
1151
+ count_event(&ws, "runtime.state.self_heal_restore_failed"),
1152
+ 0
1153
+ );
984
1154
  let failed = get_event(&ws, "runtime.state.save_failed");
985
1155
  assert_eq!(failed["phase"], json!("save_runtime_state"));
986
1156
  assert_eq!(failed["retries_used"], json!(3));
@@ -997,7 +1167,10 @@ mod tests {
997
1167
  let r = save_runtime_state(&ws, &json!({"keep":"new"}));
998
1168
  clear_fault();
999
1169
  assert!(matches!(r, Err(StateError::SaveFailed(_))));
1000
- assert_eq!(count_event(&ws, "runtime.state.self_heal_restore_failed"), 1);
1170
+ assert_eq!(
1171
+ count_event(&ws, "runtime.state.self_heal_restore_failed"),
1172
+ 1
1173
+ );
1001
1174
  assert_eq!(count_event(&ws, "runtime.state.save_failed"), 1);
1002
1175
  // state.json 已被 rename 到 backup(restore 失败),原 state 在 .bak 里完好(绝不丢失)。
1003
1176
  let baks = bak_files(&ws);
@@ -1036,8 +1209,11 @@ mod tests {
1036
1209
  let state = json!({"v":1});
1037
1210
  save_runtime_state(&ws, &state).unwrap(); // 填充缓存
1038
1211
  let _held = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap(); // 占锁
1039
- // 若 deep-equal 不早返回,会去抢已被占的锁 → 2s timeout → Locked。Ok 即证早返回。
1040
- assert!(save_runtime_state(&ws, &state).is_ok(), "deep-equal 应在取锁前返回");
1212
+ // 若 deep-equal 不早返回,会去抢已被占的锁 → 2s timeout → Locked。Ok 即证早返回。
1213
+ assert!(
1214
+ save_runtime_state(&ws, &state).is_ok(),
1215
+ "deep-equal 应在取锁前返回"
1216
+ );
1041
1217
  }
1042
1218
 
1043
1219
  // 并发全流程 save(非仅 lock acquire):多线程存不同 state → 全 Ok + 最终文件合法 JSON + 无 tmp 残留。
@@ -1075,8 +1251,13 @@ mod tests {
1075
1251
  let held = RuntimeLock::acquire(&ws, "state-save", 2.0).unwrap();
1076
1252
  // 另一线程在短 timeout 内尝试 → 应 Locked(flock 进程内/跨 fd 互斥)。
1077
1253
  let ws2 = ws.clone();
1078
- let r = std::thread::spawn(move || RuntimeLock::acquire(&ws2, "state-save", 0.2)).join().unwrap();
1079
- assert!(matches!(r, Err(StateError::Locked(_))), "持锁时第二者应 Locked");
1254
+ let r = std::thread::spawn(move || RuntimeLock::acquire(&ws2, "state-save", 0.2))
1255
+ .join()
1256
+ .unwrap();
1257
+ assert!(
1258
+ matches!(r, Err(StateError::Locked(_))),
1259
+ "持锁时第二者应 Locked"
1260
+ );
1080
1261
  drop(held);
1081
1262
  }
1082
1263
 
@@ -1090,7 +1271,10 @@ mod tests {
1090
1271
  "session_id": "keep", "rollout_path": null, "captured_at": null,
1091
1272
  "captured_via": null, "attribution_confidence": null, "spawn_cwd": null,
1092
1273
  }}});
1093
- assert_eq!(serde_json::to_string(&state).unwrap(), serde_json::to_string(&expected).unwrap());
1274
+ assert_eq!(
1275
+ serde_json::to_string(&state).unwrap(),
1276
+ serde_json::to_string(&expected).unwrap()
1277
+ );
1094
1278
  }
1095
1279
 
1096
1280
  #[test]
@@ -1116,7 +1300,10 @@ mod tests {
1116
1300
  fn load_runtime_state_missing_returns_default() {
1117
1301
  let ws = temp_ws();
1118
1302
  let s = load_runtime_state(&ws).unwrap();
1119
- assert_eq!(s, json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null}));
1303
+ assert_eq!(
1304
+ s,
1305
+ json!({"agents": {}, "tasks": [], "session_name": null, "active_team_key": null})
1306
+ );
1120
1307
  }
1121
1308
 
1122
1309
  #[test]
@@ -1130,18 +1317,31 @@ mod tests {
1130
1317
  "agents": {"w1": {"agent_id": "w1"}},
1131
1318
  "team_owner": {"pane_id": "%1", "machine_fingerprint": "fp"},
1132
1319
  });
1133
- std::fs::write(runtime_state_path(&ws), serde_json::to_string(&legacy).unwrap()).unwrap();
1320
+ std::fs::write(
1321
+ runtime_state_path(&ws),
1322
+ serde_json::to_string(&legacy).unwrap(),
1323
+ )
1324
+ .unwrap();
1134
1325
  let s = load_runtime_state(&ws).unwrap();
1135
1326
  // active_team_key seed = team_state_key = "tk"。
1136
1327
  assert_eq!(s["active_team_key"], json!("tk"));
1137
1328
  // agent session 字段补 None。
1138
1329
  assert_eq!(s["agents"]["w1"]["spawn_cwd"], json!(null));
1139
1330
  // team_owner 补 leader_session_uuid。
1140
- assert_eq!(s["team_owner"]["leader_session_uuid"].as_str().unwrap().len(), 32);
1331
+ assert_eq!(
1332
+ s["team_owner"]["leader_session_uuid"]
1333
+ .as_str()
1334
+ .unwrap()
1335
+ .len(),
1336
+ 32
1337
+ );
1141
1338
  // 迁移已回写磁盘(再 load 不再变;active_team_key 已在)。
1142
1339
  let on_disk = read_state(&ws);
1143
1340
  assert_eq!(on_disk["active_team_key"], json!("tk"));
1144
- assert_eq!(on_disk["team_owner"]["leader_session_uuid"], s["team_owner"]["leader_session_uuid"]);
1341
+ assert_eq!(
1342
+ on_disk["team_owner"]["leader_session_uuid"],
1343
+ s["team_owner"]["leader_session_uuid"]
1344
+ );
1145
1345
  }
1146
1346
 
1147
1347
  // 对抗 P1:legacy 文件**已有 active_team_key** 但缺 leader_session_uuid。load 内存补 uuid,
@@ -1156,50 +1356,392 @@ mod tests {
1156
1356
  let before = std::fs::read_to_string(runtime_state_path(&ws)).unwrap();
1157
1357
  let loaded = load_runtime_state(&ws).unwrap();
1158
1358
  // 内存态补了 uuid(证明确实需要迁移)。
1159
- assert_eq!(loaded["team_owner"]["leader_session_uuid"].as_str().unwrap().len(), 32);
1359
+ assert_eq!(
1360
+ loaded["team_owner"]["leader_session_uuid"]
1361
+ .as_str()
1362
+ .unwrap()
1363
+ .len(),
1364
+ 32
1365
+ );
1160
1366
  // 但磁盘未被重写(字节恒等)。
1161
1367
  let after = std::fs::read_to_string(runtime_state_path(&ws)).unwrap();
1162
- assert_eq!(after, before, "已是迁移等价形的 legacy 文件不得 spurious 重写");
1368
+ assert_eq!(
1369
+ after, before,
1370
+ "已是迁移等价形的 legacy 文件不得 spurious 重写"
1371
+ );
1163
1372
  }
1164
1373
 
1165
- // A0 GREEN 回归锁(.team/artifacts/a0-rs-lostupdate-locate.md §5.3):锁内 preserve 把磁盘
1166
- // latest 多出的 agent 补回 stale incoming(防 Python A0 lost-update),同时 deleted_agent_ids
1167
- // 豁免位必须让 remove-agent 的删除不被 merge 复活(persist.rs:383-385)。
1374
+ // Phase C: persist no longer repairs stale topology by cloning missing rows.
1375
+ // A stale non-lifecycle save that would remove a live row must surface a
1376
+ // typed conflict and leave disk unchanged.
1168
1377
  #[test]
1169
- fn a0_green_lock_preserve_fills_missing_agents_but_deleted_ids_stay_dead() {
1378
+ fn stale_snapshot_cannot_overwrite_new_topology() {
1170
1379
  let ws = temp_ws();
1171
- std::fs::create_dir_all(runtime_dir(&ws)).unwrap();
1172
- std::fs::write(
1173
- runtime_state_path(&ws),
1174
- serde_json::to_string_pretty(&json!({
1380
+ let latest = json!({
1381
+ "session_name": "team-a",
1382
+ "active_team_key": "team-a",
1383
+ "agents": {
1384
+ "w1": {
1385
+ "status": "running",
1386
+ "provider": "codex",
1387
+ "agent_id": "w1",
1388
+ "window": "w1-new",
1389
+ "pane_id": "%2",
1390
+ "pane_pid": 222,
1391
+ "spawned_at": "2026-06-01T00:00:00Z",
1392
+ "spawn_epoch": 2
1393
+ }
1394
+ },
1395
+ });
1396
+ write_state(&ws, &latest);
1397
+ let incoming = json!({
1398
+ "session_name": "team-a",
1399
+ "active_team_key": "team-a",
1400
+ "agents": {
1401
+ "w1": {
1402
+ "status": "running",
1403
+ "provider": "codex",
1404
+ "agent_id": "w1",
1405
+ "window": "w1-old",
1406
+ "pane_id": "%1",
1407
+ "pane_pid": 111,
1408
+ "spawned_at": "2026-05-31T00:00:00Z",
1409
+ "spawn_epoch": 1
1410
+ }
1411
+ },
1412
+ });
1413
+ let err = save_runtime_state(&ws, &incoming).expect_err("stale topology must conflict");
1414
+ assert!(matches!(err, StateError::SaveConflict(_)));
1415
+ let message = err.to_string();
1416
+ assert!(message.contains("agent_id=w1"), "message={message}");
1417
+ assert!(message.contains("projection=agents"), "message={message}");
1418
+ assert!(message.contains("pane_id"), "message={message}");
1419
+ assert_eq!(
1420
+ read_state(&ws),
1421
+ latest,
1422
+ "conflict must leave disk unchanged"
1423
+ );
1424
+ }
1425
+
1426
+ #[test]
1427
+ fn vacant_roster_preserve_cannot_resurrect_live_topology() {
1428
+ let ws = temp_ws();
1429
+ let latest = json!({
1430
+ "session_name": "team-a",
1431
+ "active_team_key": "team-a",
1432
+ "agents": {
1433
+ "w1": {"status": "running", "provider": "codex", "agent_id": "w1"},
1434
+ "joiner": {
1435
+ "status": "running",
1436
+ "provider": "codex",
1437
+ "agent_id": "joiner",
1438
+ "window": "joiner",
1439
+ "pane_id": "%9",
1440
+ "spawned_at": "2026-06-01T00:00:00Z",
1441
+ "spawn_epoch": 1
1442
+ }
1443
+ },
1444
+ });
1445
+ write_state(&ws, &latest);
1446
+ let incoming = json!({
1447
+ "session_name": "team-a",
1448
+ "active_team_key": "team-a",
1449
+ "agents": { "w1": {"status": "running", "provider": "codex", "agent_id": "w1"} },
1450
+ });
1451
+ let err = save_runtime_state(&ws, &incoming).expect_err("missing live row must conflict");
1452
+ assert!(matches!(err, StateError::SaveConflict(_)));
1453
+ assert!(err.to_string().contains("agent_id=joiner"));
1454
+ }
1455
+
1456
+ #[test]
1457
+ fn vacant_non_live_roster_preserve_is_allow_list_only() {
1458
+ let ws = temp_ws();
1459
+ write_state(
1460
+ &ws,
1461
+ &json!({
1175
1462
  "session_name": "team-a",
1176
1463
  "active_team_key": "team-a",
1177
1464
  "agents": {
1178
- "w1": {"status": "running", "provider": "codex", "agent_id": "w1"},
1179
- "kept": {"status": "running", "provider": "codex", "agent_id": "kept"},
1180
- "gone": {"status": "running", "provider": "codex", "agent_id": "gone"},
1465
+ "typed": {
1466
+ "agent_id": "typed",
1467
+ "provider": "codex",
1468
+ "role": "Developer",
1469
+ "model": "gpt-5.5",
1470
+ "status": "running",
1471
+ "spawn_cwd": "/tmp/old",
1472
+ "session_id": "old-session",
1473
+ "rollout_path": "/tmp/old.jsonl"
1474
+ }
1181
1475
  },
1182
- }))
1183
- .unwrap(),
1184
- )
1476
+ }),
1477
+ );
1478
+ let incoming = json!({
1479
+ "session_name": "team-a",
1480
+ "active_team_key": "team-a",
1481
+ "agents": {},
1482
+ });
1483
+ save_runtime_state(&ws, &incoming).unwrap();
1484
+ let saved = read_state(&ws);
1485
+ let typed = saved.pointer("/agents/typed").expect("typed stub");
1486
+ assert_eq!(typed.get("provider").and_then(Value::as_str), Some("codex"));
1487
+ assert_eq!(typed.get("role").and_then(Value::as_str), Some("Developer"));
1488
+ assert_eq!(typed.get("model").and_then(Value::as_str), Some("gpt-5.5"));
1489
+ for forbidden in ["status", "spawn_cwd", "session_id", "rollout_path"] {
1490
+ assert!(
1491
+ typed.get(forbidden).is_none(),
1492
+ "typed roster stub must not copy {forbidden}; typed={typed}"
1493
+ );
1494
+ }
1495
+ }
1496
+
1497
+ #[test]
1498
+ fn reapplying_after_conflict_retries_without_losing_delta() {
1499
+ let ws = temp_ws();
1500
+ write_state(
1501
+ &ws,
1502
+ &json!({
1503
+ "session_name": "team-a",
1504
+ "active_team_key": "team-a",
1505
+ "agents": {
1506
+ "w1": {
1507
+ "agent_id": "w1",
1508
+ "provider": "codex",
1509
+ "window": "w1-new",
1510
+ "pane_id": "%2",
1511
+ "pane_pid": 222,
1512
+ "spawn_epoch": 2
1513
+ }
1514
+ },
1515
+ "delivery": {}
1516
+ }),
1517
+ );
1518
+ let stale_with_delta = json!({
1519
+ "session_name": "team-a",
1520
+ "active_team_key": "team-a",
1521
+ "agents": {
1522
+ "w1": {
1523
+ "agent_id": "w1",
1524
+ "provider": "codex",
1525
+ "window": "w1-old",
1526
+ "pane_id": "%1",
1527
+ "pane_pid": 111,
1528
+ "spawn_epoch": 1
1529
+ }
1530
+ },
1531
+ "delivery": {"msg-1": {"status": "delivered"}}
1532
+ });
1533
+ let mut reapplied = false;
1534
+ save_runtime_state_reapplying_after_conflict(&ws, &stale_with_delta, |latest| {
1535
+ reapplied = true;
1536
+ latest.as_object_mut().unwrap().insert(
1537
+ "delivery".to_string(),
1538
+ json!({"msg-1": {"status": "delivered"}}),
1539
+ );
1540
+ })
1185
1541
  .unwrap();
1542
+
1543
+ let saved = read_state(&ws);
1544
+ assert!(reapplied, "SaveConflict path must reload and reapply once");
1545
+ assert_eq!(saved.pointer("/agents/w1/pane_id"), Some(&json!("%2")));
1546
+ assert_eq!(saved.pointer("/agents/w1/window"), Some(&json!("w1-new")));
1547
+ assert_eq!(
1548
+ saved.pointer("/delivery/msg-1/status"),
1549
+ Some(&json!("delivered")),
1550
+ "non-topology delta must survive retry; saved={saved}"
1551
+ );
1552
+ }
1553
+
1554
+ #[test]
1555
+ fn deleted_ids_stay_dead_even_when_latest_has_live_topology() {
1556
+ let ws = temp_ws();
1557
+ write_state(
1558
+ &ws,
1559
+ &json!({
1560
+ "session_name": "team-a",
1561
+ "active_team_key": "team-a",
1562
+ "agents": {
1563
+ "gone": {
1564
+ "status": "running",
1565
+ "provider": "codex",
1566
+ "agent_id": "gone",
1567
+ "window": "gone",
1568
+ "pane_id": "%7",
1569
+ "spawn_epoch": 1
1570
+ },
1571
+ },
1572
+ }),
1573
+ );
1186
1574
  let incoming = json!({
1187
1575
  "session_name": "team-a",
1188
1576
  "active_team_key": "team-a",
1189
- "agents": { "w1": {"status": "running", "provider": "codex", "agent_id": "w1"} },
1577
+ "agents": {},
1190
1578
  });
1191
1579
  save_runtime_state_with_deleted_agents(&ws, &incoming, &["gone"]).unwrap();
1192
1580
  let saved = read_state(&ws);
1193
- assert!(
1194
- saved.pointer("/agents/kept").is_some_and(Value::is_object),
1195
- "锁内 preserve 必须把磁盘 latest 多出的 `kept` 补回 stale incoming;saved={saved}"
1196
- );
1197
1581
  assert!(
1198
1582
  saved.pointer("/agents/gone").is_none(),
1199
1583
  "deleted_agent_ids 豁免:被 remove 的 `gone` 不得被 preserve 复活;saved={saved}"
1200
1584
  );
1201
1585
  }
1202
1586
 
1587
+ #[test]
1588
+ fn lifecycle_topology_authority_only_applies_to_target_agent() {
1589
+ let ws = temp_ws();
1590
+ let latest = json!({
1591
+ "session_name": "team-a",
1592
+ "active_team_key": "team-a",
1593
+ "agents": {
1594
+ "target": {"agent_id": "target", "provider": "codex", "window": "target", "pane_id": "%1", "spawn_epoch": 1},
1595
+ "other": {"agent_id": "other", "provider": "codex", "window": "other", "pane_id": "%2", "spawn_epoch": 1}
1596
+ },
1597
+ });
1598
+ write_state(&ws, &latest);
1599
+ let incoming = json!({
1600
+ "session_name": "team-a",
1601
+ "active_team_key": "team-a",
1602
+ "agents": {
1603
+ "target": {"agent_id": "target", "provider": "codex", "status": "stopped"},
1604
+ "other": {"agent_id": "other", "provider": "codex", "window": "old-other", "pane_id": "%old", "spawn_epoch": 1}
1605
+ },
1606
+ });
1607
+ let err = save_runtime_state_with_lifecycle_topology_authority(&ws, &incoming, &["target"])
1608
+ .expect_err("authorized target must not hide other agent conflict");
1609
+ assert!(matches!(err, StateError::SaveConflict(_)));
1610
+ assert!(err.to_string().contains("agent_id=other"));
1611
+ }
1612
+
1613
+ #[test]
1614
+ fn lifecycle_topology_authority_can_clear_target_topology() {
1615
+ let ws = temp_ws();
1616
+ write_state(
1617
+ &ws,
1618
+ &json!({
1619
+ "session_name": "team-a",
1620
+ "active_team_key": "team-a",
1621
+ "agents": {
1622
+ "target": {"agent_id": "target", "provider": "codex", "window": "target", "pane_id": "%1", "spawn_epoch": 1}
1623
+ },
1624
+ }),
1625
+ );
1626
+ let incoming = json!({
1627
+ "session_name": "team-a",
1628
+ "active_team_key": "team-a",
1629
+ "agents": {
1630
+ "target": {"agent_id": "target", "provider": "codex", "status": "stopped"}
1631
+ },
1632
+ });
1633
+ save_runtime_state_with_lifecycle_topology_authority(&ws, &incoming, &["target"]).unwrap();
1634
+ let target = read_state(&ws).pointer("/agents/target").cloned().unwrap();
1635
+ assert!(
1636
+ target.get("pane_id").is_none(),
1637
+ "target topology was intentionally cleared: {target}"
1638
+ );
1639
+ assert!(
1640
+ target.get("window").is_none(),
1641
+ "target window was intentionally cleared: {target}"
1642
+ );
1643
+ }
1644
+
1645
+ #[test]
1646
+ fn owner_epoch_preserve_is_explicit_and_bounded_to_team_entry() {
1647
+ let ws = temp_ws();
1648
+ write_state(
1649
+ &ws,
1650
+ &json!({
1651
+ "session_name": "team-a",
1652
+ "active_team_key": "team-a",
1653
+ "teams": {
1654
+ "team-a": {
1655
+ "agents": {},
1656
+ "team_owner": {"pane_id": "%7", "owner_epoch": 7},
1657
+ "leader_receiver": {"pane_id": "%7", "owner_epoch": 7},
1658
+ "owner_epoch": 7
1659
+ }
1660
+ }
1661
+ }),
1662
+ );
1663
+ let incoming = json!({
1664
+ "session_name": "team-a",
1665
+ "active_team_key": "team-a",
1666
+ "teams": {
1667
+ "team-a": {
1668
+ "agents": {},
1669
+ "team_owner": {"pane_id": "%3", "owner_epoch": 3},
1670
+ "leader_receiver": {"pane_id": "%3", "owner_epoch": 3},
1671
+ "owner_epoch": 3
1672
+ }
1673
+ }
1674
+ });
1675
+ save_runtime_state(&ws, &incoming).unwrap();
1676
+ let saved = read_state(&ws);
1677
+ assert_eq!(
1678
+ saved
1679
+ .pointer("/teams/team-a/owner_epoch")
1680
+ .and_then(Value::as_u64),
1681
+ Some(7)
1682
+ );
1683
+ assert_eq!(
1684
+ saved
1685
+ .pointer("/teams/team-a/team_owner/pane_id")
1686
+ .and_then(Value::as_str),
1687
+ Some("%7")
1688
+ );
1689
+ assert!(
1690
+ saved.get("team_owner").is_none(),
1691
+ "top-level owner must not be recreated: {saved}"
1692
+ );
1693
+ assert!(
1694
+ saved.get("owner_epoch").is_none(),
1695
+ "top-level owner_epoch must not be recreated: {saved}"
1696
+ );
1697
+ }
1698
+
1699
+ #[test]
1700
+ fn projection_paths_do_not_cross_backfill_topology() {
1701
+ let ws = temp_ws();
1702
+ write_state(
1703
+ &ws,
1704
+ &json!({
1705
+ "session_name": "team-a",
1706
+ "active_team_key": "team-a",
1707
+ "agents": {
1708
+ "top-only": {"agent_id": "top-only", "provider": "codex", "window": "top-only", "pane_id": "%1", "spawn_epoch": 1}
1709
+ },
1710
+ "teams": {
1711
+ "team-a": {
1712
+ "agents": {
1713
+ "team-only": {"agent_id": "team-only", "provider": "codex", "window": "team-only", "pane_id": "%2", "spawn_epoch": 1}
1714
+ }
1715
+ }
1716
+ }
1717
+ }),
1718
+ );
1719
+ let incoming = json!({
1720
+ "session_name": "team-a",
1721
+ "active_team_key": "team-a",
1722
+ "agents": {
1723
+ "top-only": {"agent_id": "top-only", "provider": "codex", "window": "top-only", "pane_id": "%1", "spawn_epoch": 1}
1724
+ },
1725
+ "teams": {
1726
+ "team-a": {
1727
+ "agents": {
1728
+ "team-only": {"agent_id": "team-only", "provider": "codex", "window": "team-only", "pane_id": "%2", "spawn_epoch": 1}
1729
+ }
1730
+ }
1731
+ }
1732
+ });
1733
+ save_runtime_state(&ws, &incoming).unwrap();
1734
+ let saved = read_state(&ws);
1735
+ assert!(
1736
+ saved.pointer("/agents/team-only").is_none(),
1737
+ "teams.<key> topology must not cross-fill top-level agents; saved={saved}"
1738
+ );
1739
+ assert!(
1740
+ saved.pointer("/teams/team-a/agents/top-only").is_none(),
1741
+ "top-level topology must not cross-fill teams.<key>.agents; saved={saved}"
1742
+ );
1743
+ }
1744
+
1203
1745
  /// RM-039-STAT-001 second-round regression (architect verdict
1204
1746
  /// 2026-06-22): `migrate_team_key_to_match_active_team` must
1205
1747
  /// promote `team_key = active_team_key` when root `team_key` is
@@ -1354,10 +1896,9 @@ mod tests {
1354
1896
  );
1355
1897
 
1356
1898
  // Re-read from disk (not cache) to confirm the migration was persisted.
1357
- let on_disk: Value = serde_json::from_str(
1358
- &std::fs::read_to_string(runtime_dir.join("state.json")).unwrap(),
1359
- )
1360
- .unwrap();
1899
+ let on_disk: Value =
1900
+ serde_json::from_str(&std::fs::read_to_string(runtime_dir.join("state.json")).unwrap())
1901
+ .unwrap();
1361
1902
  assert_eq!(
1362
1903
  on_disk.get("team_key").and_then(Value::as_str),
1363
1904
  Some(active),
@@ -1429,7 +1970,9 @@ mod tests {
1429
1970
  Some("session.captured")
1430
1971
  );
1431
1972
  assert_eq!(
1432
- incoming.get("attribution_confidence").and_then(Value::as_str),
1973
+ incoming
1974
+ .get("attribution_confidence")
1975
+ .and_then(Value::as_str),
1433
1976
  Some("high"),
1434
1977
  "attribution_confidence rides with the tuple"
1435
1978
  );