@team-agent/installer 0.5.51 → 0.5.53

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 (79) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +17 -0
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/named_address.rs +35 -67
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +11 -0
  10. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  11. package/crates/team-agent/src/kill_audit.rs +67 -0
  12. package/crates/team-agent/src/leader/lease.rs +526 -73
  13. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  14. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  15. package/crates/team-agent/src/leader/start.rs +144 -23
  16. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  17. package/crates/team-agent/src/leader/types.rs +6 -0
  18. package/crates/team-agent/src/lib.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  20. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
  21. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  24. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  26. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  29. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  30. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  32. package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
  33. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  34. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
  35. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  36. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
  38. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  41. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  42. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  43. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  44. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  45. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  46. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  47. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  48. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  49. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  50. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  51. package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
  52. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  53. package/crates/team-agent/src/messaging/mod.rs +5 -0
  54. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  55. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  56. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  57. package/crates/team-agent/src/provider/adapter.rs +54 -13
  58. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  59. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  60. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  61. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  62. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  63. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  64. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  65. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  66. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  67. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  68. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  69. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  70. package/crates/team-agent/src/provider/tests.rs +1 -0
  71. package/crates/team-agent/src/state/persist.rs +282 -20
  72. package/crates/team-agent/src/state/projection.rs +245 -30
  73. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  74. package/crates/team-agent/src/state/repository.rs +36 -12
  75. package/crates/team-agent/src/state/selector.rs +9 -18
  76. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  77. package/crates/team-agent/src/tmux_backend.rs +29 -2
  78. package/package.json +4 -4
  79. package/skills/team-agent/SKILL.md +8 -3
@@ -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)。
@@ -205,7 +205,24 @@ impl Drop for RuntimeLock {
205
205
  /// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
206
206
  /// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
207
207
  pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
208
- 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
+ )
209
226
  }
210
227
 
211
228
  pub(crate) fn save_runtime_state_reapplying_after_conflict<F>(
@@ -230,9 +247,19 @@ where
230
247
  pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
231
248
  workspace: &Path,
232
249
  state: &Value,
250
+ topology_team_key: &str,
233
251
  agent_ids: &[&str],
234
252
  ) -> Result<(), StateError> {
235
- 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
+ )
236
263
  }
237
264
 
238
265
  pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip(
@@ -248,7 +275,9 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_b
248
275
  &[],
249
276
  Some(skip_capture_backfill_team_key),
250
277
  skip_capture_backfill_agent_ids,
278
+ Some(skip_capture_backfill_team_key),
251
279
  topology_agent_ids,
280
+ None,
252
281
  )
253
282
  }
254
283
 
@@ -257,7 +286,16 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
257
286
  state: &Value,
258
287
  deleted_agent_ids: &[&str],
259
288
  ) -> Result<(), StateError> {
260
- 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
+ )
261
299
  }
262
300
 
263
301
  pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
@@ -272,7 +310,9 @@ pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
272
310
  &[],
273
311
  Some(tombstoned_team_key),
274
312
  tombstoned_agent_ids,
313
+ None,
275
314
  &[],
315
+ None,
276
316
  )
277
317
  }
278
318
 
@@ -288,7 +328,9 @@ pub(crate) fn save_runtime_state_with_team_tombstone_lifecycle_topology_authorit
288
328
  &[],
289
329
  Some(tombstoned_team_key),
290
330
  agent_ids,
331
+ Some(tombstoned_team_key),
291
332
  agent_ids,
333
+ None,
292
334
  )
293
335
  }
294
336
 
@@ -298,7 +340,9 @@ fn save_runtime_state_with_merge_options(
298
340
  deleted_agent_ids: &[&str],
299
341
  skip_capture_backfill_team_key: Option<&str>,
300
342
  skip_capture_backfill_agent_ids: &[&str],
343
+ topology_update_team_key: Option<&str>,
301
344
  topology_update_agent_ids: &[&str],
345
+ receiver_update_team_key: Option<&str>,
302
346
  ) -> Result<(), StateError> {
303
347
  let path = runtime_state_path(workspace);
304
348
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
@@ -362,10 +406,19 @@ fn save_runtime_state_with_merge_options(
362
406
  .filter(|id| !id.is_empty())
363
407
  .map(str::to_string)
364
408
  .collect::<BTreeSet<_>>();
365
- let topology_updates = topology_update_agent_ids
366
- .iter()
367
- .copied()
368
- .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())
369
422
  .map(str::to_string)
370
423
  .collect::<BTreeSet<_>>();
371
424
  apply_persist_merge_contract(
@@ -375,6 +428,7 @@ fn save_runtime_state_with_merge_options(
375
428
  skip_capture_backfill_team_key,
376
429
  &skip_capture_backfill,
377
430
  &topology_updates,
431
+ &receiver_updates,
378
432
  )?;
379
433
  }
380
434
  // Stage 3 save-output strip second pass (defence-in-depth): after the
@@ -440,7 +494,8 @@ fn apply_persist_merge_contract(
440
494
  deleted_agent_ids: &BTreeSet<String>,
441
495
  skip_capture_backfill_team_key: Option<&str>,
442
496
  skip_capture_backfill_agent_ids: &BTreeSet<String>,
443
- topology_update_agent_ids: &BTreeSet<String>,
497
+ topology_updates: &BTreeSet<(String, String)>,
498
+ receiver_updates: &BTreeSet<String>,
444
499
  ) -> Result<(), StateError> {
445
500
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
446
501
  // the top-level<->active-team cross projections depend on which team is active); the
@@ -448,10 +503,19 @@ fn apply_persist_merge_contract(
448
503
  // even when another process flipped session_name/active_team_key between this
449
504
  // writer's load and save.
450
505
  let projection_matches = same_runtime_projection(incoming, latest);
451
- 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)));
452
513
  let skip_top_level_capture_backfill =
453
514
  should_skip_capture_backfill(top_level_team.as_deref(), skip_capture_backfill_team_key);
454
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)?;
455
519
  // 0.5.26 (§7.3): top-level projection uses the incoming root state's
456
520
  // implicit "alive" shape (top-level agents are always the active team).
457
521
  // Passing `None` for team lifecycle keeps historical behavior when the
@@ -463,7 +527,7 @@ fn apply_persist_merge_contract(
463
527
  deleted_agent_ids,
464
528
  skip_top_level_capture_backfill,
465
529
  skip_capture_backfill_agent_ids,
466
- topology_update_agent_ids,
530
+ &topology_update_agent_ids,
467
531
  None,
468
532
  )?;
469
533
  // Stage 3c (identity-boundary unified plan, architect direction
@@ -500,6 +564,21 @@ fn apply_persist_merge_contract(
500
564
  let Some(incoming_entry) = incoming_teams.get_mut(team) else {
501
565
  continue;
502
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));
503
582
  let projection = format!("teams.{team}.agents");
504
583
  // 0.5.26 (`.team/artifacts/stale-team-saveconflict-locate.md` §7.3):
505
584
  // decide the containing team's aliveness from BOTH sides — a shutdown
@@ -514,6 +593,11 @@ fn apply_persist_merge_contract(
514
593
  .map(|_| crate::state::projection::team_is_alive_candidate(incoming_entry))
515
594
  .unwrap_or(true),
516
595
  );
596
+ preserve_latest_retired_agents(
597
+ incoming_entry,
598
+ latest_entry,
599
+ &topology_update_agent_ids,
600
+ )?;
517
601
  merge_agent_projection(
518
602
  &projection,
519
603
  incoming_entry.get_mut("agents"),
@@ -521,16 +605,79 @@ fn apply_persist_merge_contract(
521
605
  deleted_agent_ids,
522
606
  should_skip_capture_backfill(Some(team), skip_capture_backfill_team_key),
523
607
  skip_capture_backfill_agent_ids,
524
- topology_update_agent_ids,
608
+ &topology_update_agent_ids,
525
609
  team_alive,
526
610
  )?;
527
- preserve_latest_ownership_fields(incoming_entry, latest_entry);
611
+ preserve_latest_ownership_fields(
612
+ incoming_entry,
613
+ latest_entry,
614
+ receiver_update_authoritative,
615
+ );
528
616
  preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
529
617
  }
530
618
  }
531
619
  Ok(())
532
620
  }
533
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
+
534
681
  /// 0.5.x Windows portability Batch 7 F6: copy `latest.transport.shim`
535
682
  /// into `incoming` if the incoming save omitted it. The coordinator's
536
683
  /// `conpty_shim::finalize` writes `state.transport.shim = {pid,
@@ -578,8 +725,12 @@ fn should_skip_capture_backfill(
578
725
  }
579
726
  }
580
727
 
581
- fn preserve_latest_ownership_fields(incoming: &mut Value, latest: &Value) {
582
- 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) {
583
734
  return;
584
735
  }
585
736
  let Some(incoming_obj) = incoming.as_object_mut() else {
@@ -643,13 +794,19 @@ fn endpoint_convergence_epoch(state: &Value) -> Option<u64> {
643
794
  .and_then(Value::as_u64)
644
795
  }
645
796
 
646
- 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 {
647
802
  let latest_epoch = ownership_epoch(latest);
648
803
  let incoming_epoch = ownership_epoch(incoming);
649
804
  if latest_epoch > incoming_epoch {
650
805
  return true;
651
806
  }
652
- 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))
653
810
  }
654
811
 
655
812
  fn ownership_epoch(state: &Value) -> u64 {
@@ -1809,8 +1966,13 @@ mod tests {
1809
1966
  "other": {"agent_id": "other", "provider": "codex", "window": "old-other", "pane_id": "%old", "spawn_epoch": 1}
1810
1967
  },
1811
1968
  });
1812
- let err = save_runtime_state_with_lifecycle_topology_authority(&ws, &incoming, &["target"])
1813
- .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");
1814
1976
  assert!(matches!(err, StateError::SaveConflict(_)));
1815
1977
  assert!(err.to_string().contains("agent_id=other"));
1816
1978
  }
@@ -1835,7 +1997,13 @@ mod tests {
1835
1997
  "target": {"agent_id": "target", "provider": "codex", "status": "stopped"}
1836
1998
  },
1837
1999
  });
1838
- 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();
1839
2007
  let target = read_state(&ws).pointer("/agents/target").cloned().unwrap();
1840
2008
  assert!(
1841
2009
  target.get("pane_id").is_none(),
@@ -2418,4 +2586,98 @@ mod tests {
2418
2586
  "0.4.6: partial poisoned latest must not resurrect cleared session_id; got {alpha}"
2419
2587
  );
2420
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
+ }
2421
2683
  }