@team-agent/installer 0.5.50 → 0.5.52

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 (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. package/package.json +4 -4
@@ -33,7 +33,7 @@ use crate::event_log::EventLog;
33
33
  use crate::model::paths::runtime_dir;
34
34
  use crate::state::identity::{migrate_state_identity, SystemEnv};
35
35
  use crate::state::json_truthy;
36
- use crate::state::projection::team_state_key;
36
+ use crate::state::projection::{team_state_key, CURRENT_TEAM_ALIAS};
37
37
  use crate::state::StateError;
38
38
 
39
39
  /// `state.py:26-29`:agent session-state 归一字段(setdefault None)。
@@ -47,7 +47,7 @@ const SESSION_STATE_FIELDS: [&str; 6] = [
47
47
  ];
48
48
  const LIVE_TOPOLOGY_FIELDS: [&str; 5] =
49
49
  ["pane_id", "pane_pid", "window", "spawned_at", "spawn_epoch"];
50
- const ROSTER_STUB_ALLOWLIST: [&str; 15] = [
50
+ const ROSTER_STUB_ALLOWLIST: [&str; 16] = [
51
51
  "agent_id",
52
52
  "provider",
53
53
  "auth_mode",
@@ -57,6 +57,7 @@ const ROSTER_STUB_ALLOWLIST: [&str; 15] = [
57
57
  "profile",
58
58
  "_profile_dir",
59
59
  "dynamic_role_file",
60
+ "role_source_ownership",
60
61
  "effort",
61
62
  "forked_from",
62
63
  "managed_mcp_config",
@@ -204,7 +205,24 @@ impl Drop for RuntimeLock {
204
205
  /// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
205
206
  /// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
206
207
  pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
207
- save_runtime_state_with_merge_options(workspace, state, &[], None, &[], &[])
208
+ save_runtime_state_with_merge_options(workspace, state, &[], None, &[], None, &[], None)
209
+ }
210
+
211
+ pub(crate) fn save_runtime_state_with_receiver_authority(
212
+ workspace: &Path,
213
+ state: &Value,
214
+ receiver_team_key: &str,
215
+ ) -> Result<(), StateError> {
216
+ save_runtime_state_with_merge_options(
217
+ workspace,
218
+ state,
219
+ &[],
220
+ None,
221
+ &[],
222
+ None,
223
+ &[],
224
+ Some(receiver_team_key),
225
+ )
208
226
  }
209
227
 
210
228
  pub(crate) fn save_runtime_state_reapplying_after_conflict<F>(
@@ -229,9 +247,19 @@ where
229
247
  pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
230
248
  workspace: &Path,
231
249
  state: &Value,
250
+ topology_team_key: &str,
232
251
  agent_ids: &[&str],
233
252
  ) -> Result<(), StateError> {
234
- save_runtime_state_with_merge_options(workspace, state, &[], None, &[], agent_ids)
253
+ save_runtime_state_with_merge_options(
254
+ workspace,
255
+ state,
256
+ &[],
257
+ None,
258
+ &[],
259
+ Some(topology_team_key),
260
+ agent_ids,
261
+ None,
262
+ )
235
263
  }
236
264
 
237
265
  pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
@@ -247,7 +275,9 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_b
247
275
  &[],
248
276
  Some(skip_capture_backfill_team_key),
249
277
  skip_capture_backfill_agent_ids,
278
+ Some(skip_capture_backfill_team_key),
250
279
  topology_agent_ids,
280
+ None,
251
281
  )
252
282
  }
253
283
 
@@ -256,7 +286,16 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
256
286
  state: &Value,
257
287
  deleted_agent_ids: &[&str],
258
288
  ) -> Result<(), StateError> {
259
- save_runtime_state_with_merge_options(workspace, state, deleted_agent_ids, None, &[], &[])
289
+ save_runtime_state_with_merge_options(
290
+ workspace,
291
+ state,
292
+ deleted_agent_ids,
293
+ None,
294
+ &[],
295
+ None,
296
+ &[],
297
+ None,
298
+ )
260
299
  }
261
300
 
262
301
  pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
@@ -271,7 +310,9 @@ pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
271
310
  &[],
272
311
  Some(tombstoned_team_key),
273
312
  tombstoned_agent_ids,
313
+ None,
274
314
  &[],
315
+ None,
275
316
  )
276
317
  }
277
318
 
@@ -287,7 +328,9 @@ pub(crate) fn save_runtime_state_with_team_tombstone_lifecycle_topology_authorit
287
328
  &[],
288
329
  Some(tombstoned_team_key),
289
330
  agent_ids,
331
+ Some(tombstoned_team_key),
290
332
  agent_ids,
333
+ None,
291
334
  )
292
335
  }
293
336
 
@@ -297,7 +340,9 @@ fn save_runtime_state_with_merge_options(
297
340
  deleted_agent_ids: &[&str],
298
341
  skip_capture_backfill_team_key: Option<&str>,
299
342
  skip_capture_backfill_agent_ids: &[&str],
343
+ topology_update_team_key: Option<&str>,
300
344
  topology_update_agent_ids: &[&str],
345
+ receiver_update_team_key: Option<&str>,
301
346
  ) -> Result<(), StateError> {
302
347
  let path = runtime_state_path(workspace);
303
348
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
@@ -361,10 +406,19 @@ fn save_runtime_state_with_merge_options(
361
406
  .filter(|id| !id.is_empty())
362
407
  .map(str::to_string)
363
408
  .collect::<BTreeSet<_>>();
364
- let topology_updates = topology_update_agent_ids
365
- .iter()
366
- .copied()
367
- .filter(|id| !id.is_empty())
409
+ let topology_updates = topology_update_team_key
410
+ .into_iter()
411
+ .flat_map(|team_key| {
412
+ topology_update_agent_ids
413
+ .iter()
414
+ .copied()
415
+ .filter(|id| !id.is_empty())
416
+ .map(move |agent_id| (team_key.to_string(), agent_id.to_string()))
417
+ })
418
+ .collect::<BTreeSet<_>>();
419
+ let receiver_updates = receiver_update_team_key
420
+ .into_iter()
421
+ .filter(|team_key| !team_key.is_empty())
368
422
  .map(str::to_string)
369
423
  .collect::<BTreeSet<_>>();
370
424
  apply_persist_merge_contract(
@@ -374,6 +428,7 @@ fn save_runtime_state_with_merge_options(
374
428
  skip_capture_backfill_team_key,
375
429
  &skip_capture_backfill,
376
430
  &topology_updates,
431
+ &receiver_updates,
377
432
  )?;
378
433
  }
379
434
  // Stage 3 save-output strip second pass (defence-in-depth): after the
@@ -439,7 +494,8 @@ fn apply_persist_merge_contract(
439
494
  deleted_agent_ids: &BTreeSet<String>,
440
495
  skip_capture_backfill_team_key: Option<&str>,
441
496
  skip_capture_backfill_agent_ids: &BTreeSet<String>,
442
- topology_update_agent_ids: &BTreeSet<String>,
497
+ topology_updates: &BTreeSet<(String, String)>,
498
+ receiver_updates: &BTreeSet<String>,
443
499
  ) -> Result<(), StateError> {
444
500
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
445
501
  // the top-level<->active-team cross projections depend on which team is active); the
@@ -447,10 +503,19 @@ fn apply_persist_merge_contract(
447
503
  // even when another process flipped session_name/active_team_key between this
448
504
  // writer's load and save.
449
505
  let projection_matches = same_runtime_projection(incoming, latest);
450
- let top_level_team = Some(team_state_key(incoming)).or_else(|| Some(team_state_key(latest)));
506
+ let top_level_team = incoming
507
+ .get("active_team_key")
508
+ .and_then(Value::as_str)
509
+ .filter(|key| !key.is_empty())
510
+ .map(str::to_string)
511
+ .or_else(|| Some(team_state_key(incoming)))
512
+ .or_else(|| Some(team_state_key(latest)));
451
513
  let skip_top_level_capture_backfill =
452
514
  should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
453
515
  if projection_matches {
516
+ let topology_update_agent_ids =
517
+ topology_updates_for_team(topology_updates, top_level_team.as_deref());
518
+ preserve_latest_retired_agents(incoming, latest, &topology_update_agent_ids)?;
454
519
  // 0.5.26 (§7.3): top-level projection uses the incoming root state's
455
520
  // implicit "alive" shape (top-level agents are always the active team).
456
521
  // Passing `None` for team lifecycle keeps historical behavior when the
@@ -462,7 +527,7 @@ fn apply_persist_merge_contract(
462
527
  deleted_agent_ids,
463
528
  skip_top_level_capture_backfill,
464
529
  skip_capture_backfill_agent_ids,
465
- topology_update_agent_ids,
530
+ &topology_update_agent_ids,
466
531
  None,
467
532
  )?;
468
533
  // Stage 3c (identity-boundary unified plan, architect direction
@@ -499,6 +564,21 @@ fn apply_persist_merge_contract(
499
564
  let Some(incoming_entry) = incoming_teams.get_mut(team) else {
500
565
  continue;
501
566
  };
567
+ // Intentional semantic split with
568
+ // `projection::resolve_runtime_team_scope`: that selector checks
569
+ // alive/terminal state, but this lock-held merge cannot depend on
570
+ // the mutable alive view. A change to either CURRENT_TEAM_ALIAS
571
+ // mapping must evaluate the other. The legacy physical alias gets
572
+ // active-team authority; every real sibling remains self-scoped.
573
+ let authority_team = if team == CURRENT_TEAM_ALIAS {
574
+ top_level_team.as_deref()
575
+ } else {
576
+ Some(team.as_str())
577
+ };
578
+ let topology_update_agent_ids =
579
+ topology_updates_for_team(topology_updates, authority_team);
580
+ let receiver_update_authoritative =
581
+ authority_team.is_some_and(|team_key| receiver_updates.contains(team_key));
502
582
  let projection = format!("teams.{team}.agents");
503
583
  // 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.3):
504
584
  // decide the containing team's aliveness from BOTH sides — a shutdown
@@ -513,6 +593,11 @@ fn apply_persist_merge_contract(
513
593
  .map(|_| crate::state::projection::team_is_alive_candidate(incoming_entry))
514
594
  .unwrap_or(true),
515
595
  );
596
+ preserve_latest_retired_agents(
597
+ incoming_entry,
598
+ latest_entry,
599
+ &topology_update_agent_ids,
600
+ )?;
516
601
  merge_agent_projection(
517
602
  &projection,
518
603
  incoming_entry.get_mut("agents"),
@@ -520,16 +605,79 @@ fn apply_persist_merge_contract(
520
605
  deleted_agent_ids,
521
606
  should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
522
607
  skip_capture_backfill_agent_ids,
523
- topology_update_agent_ids,
608
+ &topology_update_agent_ids,
524
609
  team_alive,
525
610
  )?;
526
- preserve_latest_ownership_fields(incoming_entry, latest_entry);
611
+ preserve_latest_ownership_fields(
612
+ incoming_entry,
613
+ latest_entry,
614
+ receiver_update_authoritative,
615
+ );
527
616
  preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
528
617
  }
529
618
  }
530
619
  Ok(())
531
620
  }
532
621
 
622
+ fn topology_updates_for_team(
623
+ topology_updates: &BTreeSet<(String, String)>,
624
+ team_key: Option<&str>,
625
+ ) -> BTreeSet<String> {
626
+ topology_updates
627
+ .iter()
628
+ .filter(|(authority_team, _)| Some(authority_team.as_str()) == team_key)
629
+ .map(|(_, agent_id)| agent_id.clone())
630
+ .collect()
631
+ }
632
+
633
+ fn preserve_latest_retired_agents(
634
+ incoming: &mut Value,
635
+ latest: &Value,
636
+ topology_update_agent_ids: &BTreeSet<String>,
637
+ ) -> Result<(), StateError> {
638
+ // A persisted retirement is lifecycle authority, so an ordinary stale
639
+ // writer (notably a coordinator tick captured before remove-agent) cannot
640
+ // resurrect its old roster row. Explicit lifecycle topology writers are
641
+ // the only override: AddAgent uses that existing authority to consume the
642
+ // tombstone, while remove itself supplies the newer tombstone as incoming.
643
+ let retired = latest
644
+ .get("agent_lifecycle")
645
+ .and_then(Value::as_object)
646
+ .into_iter()
647
+ .flat_map(|entries| entries.iter())
648
+ .filter(|(agent_id, entry)| {
649
+ !topology_update_agent_ids.contains(*agent_id)
650
+ && entry.get("state").and_then(Value::as_str) == Some("retired")
651
+ })
652
+ .map(|(agent_id, entry)| (agent_id.clone(), entry.clone()))
653
+ .collect::<Vec<_>>();
654
+ if retired.is_empty() {
655
+ return Ok(());
656
+ }
657
+ let Some(incoming) = incoming.as_object_mut() else {
658
+ return Err(StateError::SaveFailed(
659
+ "incoming runtime projection is not an object".to_string(),
660
+ ));
661
+ };
662
+ if let Some(agents) = incoming.get_mut("agents").and_then(Value::as_object_mut) {
663
+ for (agent_id, _) in &retired {
664
+ agents.remove(agent_id);
665
+ }
666
+ }
667
+ let lifecycle = incoming
668
+ .entry("agent_lifecycle".to_string())
669
+ .or_insert_with(|| serde_json::json!({}));
670
+ let Some(lifecycle) = lifecycle.as_object_mut() else {
671
+ return Err(StateError::SaveFailed(
672
+ "incoming agent_lifecycle is not an object".to_string(),
673
+ ));
674
+ };
675
+ for (agent_id, entry) in retired {
676
+ lifecycle.insert(agent_id, entry);
677
+ }
678
+ Ok(())
679
+ }
680
+
533
681
  /// 0.5.x Windows portability Batch 7 F6: copy `latest.transport.shim`
534
682
  /// into `incoming` if the incoming save omitted it. The coordinator's
535
683
  /// `conpty_shim::finalize` writes `state.transport.shim = {pid,
@@ -577,8 +725,12 @@ fn should_skip_capture_backfill(
577
725
  }
578
726
  }
579
727
 
580
- fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
581
- if !latest_has_preferable_ownership(incoming, latest) {
728
+ fn preserve_latest_ownership_fields(
729
+ incoming: &mut Value,
730
+ latest: &Value,
731
+ receiver_update_authoritative: bool,
732
+ ) {
733
+ if !latest_has_preferable_ownership(incoming, latest, receiver_update_authoritative) {
582
734
  return;
583
735
  }
584
736
  let Some(incoming_obj) = incoming.as_object_mut() else {
@@ -642,13 +794,19 @@ fn endpoint_convergence_epoch(state: &Value) -> Option<u64> {
642
794
  .and_then(Value::as_u64)
643
795
  }
644
796
 
645
- fn latest_has_preferable_ownership(incoming: &Value, latest: &Value) -> bool {
797
+ fn latest_has_preferable_ownership(
798
+ incoming: &Value,
799
+ latest: &Value,
800
+ receiver_update_authoritative: bool,
801
+ ) -> bool {
646
802
  let latest_epoch = ownership_epoch(latest);
647
803
  let incoming_epoch = ownership_epoch(incoming);
648
804
  if latest_epoch > incoming_epoch {
649
805
  return true;
650
806
  }
651
- latest_epoch == incoming_epoch && !ownership_attached(incoming) && ownership_attached(latest)
807
+ latest_epoch == incoming_epoch
808
+ && !receiver_update_authoritative
809
+ && (ownership_attached(latest) || !ownership_attached(incoming))
652
810
  }
653
811
 
654
812
  fn ownership_epoch(state: &Value) -> u64 {
@@ -1808,8 +1966,13 @@ mod tests {
1808
1966
  "other": {"agent_id": "other", "provider": "codex", "window": "old-other", "pane_id": "%old", "spawn_epoch": 1}
1809
1967
  },
1810
1968
  });
1811
- let err = save_runtime_state_with_lifecycle_topology_authority(&ws, &incoming, &["target"])
1812
- .expect_err("authorized target must not hide other agent conflict");
1969
+ let err = save_runtime_state_with_lifecycle_topology_authority(
1970
+ &ws,
1971
+ &incoming,
1972
+ "team-a",
1973
+ &["target"],
1974
+ )
1975
+ .expect_err("authorized target must not hide other agent conflict");
1813
1976
  assert!(matches!(err, StateError::SaveConflict(_)));
1814
1977
  assert!(err.to_string().contains("agent_id=other"));
1815
1978
  }
@@ -1834,7 +1997,13 @@ mod tests {
1834
1997
  "target": {"agent_id": "target", "provider": "codex", "status": "stopped"}
1835
1998
  },
1836
1999
  });
1837
- save_runtime_state_with_lifecycle_topology_authority(&ws, &incoming, &["target"]).unwrap();
2000
+ save_runtime_state_with_lifecycle_topology_authority(
2001
+ &ws,
2002
+ &incoming,
2003
+ "team-a",
2004
+ &["target"],
2005
+ )
2006
+ .unwrap();
1838
2007
  let target = read_state(&ws).pointer("/agents/target").cloned().unwrap();
1839
2008
  assert!(
1840
2009
  target.get("pane_id").is_none(),
@@ -2417,4 +2586,98 @@ mod tests {
2417
2586
  "0.4.6: partial poisoned latest must not resurrect cleared session_id; got {alpha}"
2418
2587
  );
2419
2588
  }
2589
+
2590
+ fn ownership_projection(session: &str, tty: &str) -> Value {
2591
+ let entry = json!({
2592
+ "session_name": "team-authority-merge",
2593
+ "owner_epoch": 10,
2594
+ "team_owner": {"status": "attached", "owner_epoch": 10, "pane_id": "%7"},
2595
+ "leader_receiver": {
2596
+ "status": "attached",
2597
+ "owner_epoch": 10,
2598
+ "pane_id": "%7",
2599
+ "session_name": session,
2600
+ "pane_tty": tty
2601
+ }
2602
+ });
2603
+ let mut state = json!({
2604
+ "session_name": "team-authority-merge",
2605
+ "active_team_key": "team-authority-merge",
2606
+ "teams": {
2607
+ "team-authority-merge": entry.clone(),
2608
+ "sibling": entry.clone()
2609
+ }
2610
+ });
2611
+ state["teams"][CURRENT_TEAM_ALIAS] = entry;
2612
+ state
2613
+ }
2614
+
2615
+ #[test]
2616
+ fn equal_epoch_ordinary_writer_preserves_latest_receiver_observation() {
2617
+ let mut incoming = ownership_projection("stale-session", "/dev/stale");
2618
+ let latest = ownership_projection("refreshed-session", "/dev/refreshed");
2619
+ apply_persist_merge_contract(
2620
+ &mut incoming,
2621
+ &latest,
2622
+ &BTreeSet::new(),
2623
+ None,
2624
+ &BTreeSet::new(),
2625
+ &BTreeSet::new(),
2626
+ &BTreeSet::new(),
2627
+ )
2628
+ .unwrap();
2629
+
2630
+ assert_eq!(
2631
+ incoming.pointer("/teams/team-authority-merge/leader_receiver/session_name"),
2632
+ Some(&json!("refreshed-session"))
2633
+ );
2634
+ assert_eq!(
2635
+ incoming.pointer("/teams/team-authority-merge/leader_receiver/pane_tty"),
2636
+ Some(&json!("/dev/refreshed"))
2637
+ );
2638
+ assert_eq!(
2639
+ incoming["teams"][CURRENT_TEAM_ALIAS]["leader_receiver"]["session_name"],
2640
+ json!("refreshed-session")
2641
+ );
2642
+ assert_eq!(
2643
+ incoming["teams"]["sibling"]["leader_receiver"]["session_name"],
2644
+ json!("refreshed-session")
2645
+ );
2646
+ }
2647
+
2648
+ #[test]
2649
+ fn equal_epoch_receiver_authority_keeps_incoming_refresh() {
2650
+ let mut incoming = ownership_projection("refreshed-session", "/dev/refreshed");
2651
+ let latest = ownership_projection("stale-session", "/dev/stale");
2652
+ let receiver_updates = BTreeSet::from(["team-authority-merge".to_string()]);
2653
+ apply_persist_merge_contract(
2654
+ &mut incoming,
2655
+ &latest,
2656
+ &BTreeSet::new(),
2657
+ None,
2658
+ &BTreeSet::new(),
2659
+ &BTreeSet::new(),
2660
+ &receiver_updates,
2661
+ )
2662
+ .unwrap();
2663
+
2664
+ assert_eq!(
2665
+ incoming.pointer("/teams/team-authority-merge/leader_receiver/session_name"),
2666
+ Some(&json!("refreshed-session"))
2667
+ );
2668
+ assert_eq!(
2669
+ incoming.pointer("/teams/team-authority-merge/leader_receiver/pane_tty"),
2670
+ Some(&json!("/dev/refreshed"))
2671
+ );
2672
+ assert_eq!(
2673
+ incoming["teams"][CURRENT_TEAM_ALIAS]["leader_receiver"]["session_name"],
2674
+ json!("refreshed-session"),
2675
+ "current alias must inherit the active team's receiver authority"
2676
+ );
2677
+ assert_eq!(
2678
+ incoming["teams"]["sibling"]["leader_receiver"]["session_name"],
2679
+ json!("stale-session"),
2680
+ "team-scoped receiver authority must not overwrite a sibling's latest tuple"
2681
+ );
2682
+ }
2420
2683
  }