@team-agent/installer 0.5.73 → 0.5.75

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 (38) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +19 -13
  4. package/crates/team-agent/src/cli/emit.rs +6 -3
  5. package/crates/team-agent/src/cli/mod.rs +151 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
  8. package/crates/team-agent/src/cli/types.rs +6 -0
  9. package/crates/team-agent/src/compiler.rs +14 -1
  10. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  11. package/crates/team-agent/src/leader/lease.rs +334 -53
  12. package/crates/team-agent/src/leader/registry.rs +177 -108
  13. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
  14. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
  15. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
  16. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  17. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  18. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
  21. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
  22. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
  23. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  24. package/crates/team-agent/src/messaging/delivery.rs +226 -37
  25. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  26. package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
  27. package/crates/team-agent/src/model/spec.rs +81 -7
  28. package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
  29. package/crates/team-agent/src/state/persist.rs +433 -4
  30. package/crates/team-agent/src/state/repository.rs +45 -2
  31. package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
  32. package/crates/team-agent/src/tmux_backend.rs +274 -26
  33. package/crates/team-agent/src/transport/test_support.rs +18 -3
  34. package/crates/team-agent/src/transport.rs +99 -0
  35. package/package.json +4 -4
  36. package/skills/team-agent/SKILL.md +3 -0
  37. package/skills/team-agent/command-coverage.json +21 -0
  38. package/skills/team-agent/references/team-agent-operator.md +9 -2
@@ -208,13 +208,24 @@ impl Drop for RuntimeLock {
208
208
  /// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
209
209
  /// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
210
210
  pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
211
- save_runtime_state_with_merge_options(workspace, state, &[], None, &[], None, &[], None)
211
+ save_runtime_state_with_merge_options(
212
+ workspace,
213
+ state,
214
+ &[],
215
+ None,
216
+ &[],
217
+ None,
218
+ &[],
219
+ None,
220
+ None,
221
+ )
212
222
  }
213
223
 
214
224
  pub(crate) fn save_runtime_state_with_receiver_authority(
215
225
  workspace: &Path,
216
226
  state: &Value,
217
227
  receiver_team_key: &str,
228
+ exact_owner_seed_to_clear: Option<&Value>,
218
229
  ) -> Result<(), StateError> {
219
230
  save_runtime_state_with_merge_options(
220
231
  workspace,
@@ -225,6 +236,7 @@ pub(crate) fn save_runtime_state_with_receiver_authority(
225
236
  None,
226
237
  &[],
227
238
  Some(receiver_team_key),
239
+ exact_owner_seed_to_clear,
228
240
  )
229
241
  }
230
242
 
@@ -262,6 +274,7 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority(
262
274
  Some(topology_team_key),
263
275
  agent_ids,
264
276
  None,
277
+ None,
265
278
  )
266
279
  }
267
280
 
@@ -281,6 +294,7 @@ pub(crate) fn save_runtime_state_with_lifecycle_topology_authority_and_capture_b
281
294
  Some(skip_capture_backfill_team_key),
282
295
  topology_agent_ids,
283
296
  None,
297
+ None,
284
298
  )
285
299
  }
286
300
 
@@ -298,6 +312,7 @@ pub(crate) fn save_runtime_state_with_deleted_agents(
298
312
  None,
299
313
  &[],
300
314
  None,
315
+ None,
301
316
  )
302
317
  }
303
318
 
@@ -316,6 +331,7 @@ pub(crate) fn save_runtime_state_with_team_tombstoned_agents(
316
331
  None,
317
332
  &[],
318
333
  None,
334
+ None,
319
335
  )
320
336
  }
321
337
 
@@ -334,6 +350,7 @@ pub(crate) fn save_runtime_state_with_team_tombstone_lifecycle_topology_authorit
334
350
  Some(tombstoned_team_key),
335
351
  agent_ids,
336
352
  None,
353
+ None,
337
354
  )
338
355
  }
339
356
 
@@ -346,6 +363,79 @@ fn save_runtime_state_with_merge_options(
346
363
  topology_update_team_key: Option<&str>,
347
364
  topology_update_agent_ids: &[&str],
348
365
  receiver_update_team_key: Option<&str>,
366
+ exact_owner_seed_to_clear: Option<&Value>,
367
+ ) -> Result<(), StateError> {
368
+ persist_runtime_state_with_merge_options_and_expected(
369
+ workspace,
370
+ state,
371
+ deleted_agent_ids,
372
+ skip_capture_backfill_team_key,
373
+ skip_capture_backfill_agent_ids,
374
+ topology_update_team_key,
375
+ topology_update_agent_ids,
376
+ receiver_update_team_key,
377
+ exact_owner_seed_to_clear,
378
+ None,
379
+ None,
380
+ )
381
+ }
382
+
383
+ pub(crate) fn save_runtime_state_with_receiver_authority_and_expected(
384
+ workspace: &Path,
385
+ state: &Value,
386
+ receiver_team_key: &str,
387
+ expected_owner: &Value,
388
+ expected_receiver: &Value,
389
+ ) -> Result<(), StateError> {
390
+ persist_runtime_state_with_merge_options_and_expected(
391
+ workspace,
392
+ state,
393
+ &[],
394
+ None,
395
+ &[],
396
+ None,
397
+ &[],
398
+ Some(receiver_team_key),
399
+ None,
400
+ Some((expected_owner, expected_receiver)),
401
+ None,
402
+ )
403
+ }
404
+
405
+ pub(crate) fn save_runtime_state_with_exact_owner_receiver_cleanup(
406
+ workspace: &Path,
407
+ state: &Value,
408
+ team_key: &str,
409
+ expected_owner: &Value,
410
+ expected_receiver: &Value,
411
+ ) -> Result<(), StateError> {
412
+ persist_runtime_state_with_merge_options_and_expected(
413
+ workspace,
414
+ state,
415
+ &[],
416
+ None,
417
+ &[],
418
+ None,
419
+ &[],
420
+ Some(team_key),
421
+ None,
422
+ None,
423
+ Some((team_key, expected_owner, expected_receiver)),
424
+ )
425
+ }
426
+
427
+ fn persist_runtime_state_with_merge_options_and_expected(
428
+ workspace: &Path,
429
+ state: &Value,
430
+ deleted_agent_ids: &[&str],
431
+ skip_capture_backfill_team_key: Option<&str>,
432
+ skip_capture_backfill_agent_ids: &[&str],
433
+ topology_update_team_key: Option<&str>,
434
+ topology_update_agent_ids: &[&str],
435
+ receiver_update_team_key: Option<&str>,
436
+ exact_owner_seed_to_clear: Option<&Value>,
437
+ expected_owner_receiver: Option<(&Value, &Value)>,
438
+ exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
349
439
  ) -> Result<(), StateError> {
350
440
  let path = runtime_state_path(workspace);
351
441
  // Python `state.py:497`:先对入参 state 跑 `_migrate_state_identity`(就地填缺失 leader uuid)。
@@ -364,14 +454,20 @@ fn save_runtime_state_with_merge_options(
364
454
  // entirely. Moving migrate+strip ahead of every cache_equals makes
365
455
  // the canonical-only shape the cache invariant for new writes.
366
456
  crate::state::ownership::strip_top_level_ownership_if_canonical_present(&mut migrated);
367
- if cache_equals(&path, &migrated) {
457
+ // Exact-seed cleanup must observe lock-held disk, not a pre-lock cache or
458
+ // byte-equality hit. A matching in-memory document would otherwise no-op
459
+ // and leave the seed, or skip copying a concurrent owner.
460
+ let skip_pre_lock_fast_path = exact_owner_seed_to_clear.is_some()
461
+ || expected_owner_receiver.is_some()
462
+ || exact_owner_receiver_to_clear.is_some();
463
+ if !skip_pre_lock_fast_path && cache_equals(&path, &migrated) {
368
464
  return Ok(());
369
465
  }
370
466
  // 与磁盘已有内容「迁移后」相同 → 更新缓存返回(避免无谓重写)。字节对拍 Python:对 `existing` 先
371
467
  // `normalize_agent_session_state` + `_migrate_state_identity` 再比(读/迁移失败 try/except: pass →
372
468
  // 落写路径)。**修对抗 P1**:此前比较 raw `existing` 漏了这两步,会把「磁盘已是迁移等价形」的 legacy
373
469
  // 文件误判为不同而 spurious 重写,破坏 load+save 字节恒等。
374
- if path.exists() {
470
+ if !skip_pre_lock_fast_path && path.exists() {
375
471
  if let Ok(text) = std::fs::read_to_string(&path) {
376
472
  if let Ok(mut existing) = serde_json::from_str::<Value>(&text) {
377
473
  normalize_agent_session_state(&mut existing);
@@ -396,7 +492,21 @@ fn save_runtime_state_with_merge_options(
396
492
  if let Some(parent) = path.parent() {
397
493
  std::fs::create_dir_all(parent)?;
398
494
  }
399
- if let Some(latest) = read_latest_state_under_lock(workspace, &path) {
495
+ let latest = read_latest_state_under_lock(workspace, &path);
496
+ if let Some((expected_owner, expected_receiver)) = expected_owner_receiver {
497
+ let expected_matches = latest.as_ref().is_some_and(|latest| {
498
+ latest_team_owner_receiver(latest, receiver_update_team_key)
499
+ .is_some_and(|(owner, receiver)| {
500
+ owner == expected_owner && receiver == expected_receiver
501
+ })
502
+ });
503
+ if !expected_matches {
504
+ return Err(StateError::SaveConflict(
505
+ "fresh caller owner/receiver changed before commit".to_string(),
506
+ ));
507
+ }
508
+ }
509
+ if let Some(latest) = latest {
400
510
  let deleted = deleted_agent_ids
401
511
  .iter()
402
512
  .copied()
@@ -432,6 +542,8 @@ fn save_runtime_state_with_merge_options(
432
542
  &skip_capture_backfill,
433
543
  &topology_updates,
434
544
  &receiver_updates,
545
+ receiver_update_team_key.zip(exact_owner_seed_to_clear),
546
+ exact_owner_receiver_to_clear,
435
547
  )?;
436
548
  }
437
549
  // Stage 3 save-output strip second pass (defence-in-depth): after the
@@ -499,6 +611,8 @@ fn apply_persist_merge_contract(
499
611
  skip_capture_backfill_agent_ids: &BTreeSet<String>,
500
612
  topology_updates: &BTreeSet<(String, String)>,
501
613
  receiver_updates: &BTreeSet<String>,
614
+ exact_owner_seed_to_clear: Option<(&str, &Value)>,
615
+ exact_owner_receiver_to_clear: Option<(&str, &Value, &Value)>,
502
616
  ) -> Result<(), StateError> {
503
617
  // A0/R1: the projection gate only guards the TOP-LEVEL passes (top-level agents and
504
618
  // the top-level<->active-team cross projections depend on which team is active); the
@@ -616,6 +730,21 @@ fn apply_persist_merge_contract(
616
730
  latest_entry,
617
731
  receiver_update_authoritative,
618
732
  );
733
+ if let Some((clear_team, seed)) = exact_owner_seed_to_clear {
734
+ if team == clear_team {
735
+ apply_lock_held_exact_owner_cleanup(incoming_entry, latest_entry, seed);
736
+ }
737
+ }
738
+ if let Some((clear_team, seed, expected_receiver)) = exact_owner_receiver_to_clear {
739
+ if team == clear_team {
740
+ apply_lock_held_exact_owner_receiver_cleanup(
741
+ incoming_entry,
742
+ latest_entry,
743
+ seed,
744
+ Some(expected_receiver),
745
+ );
746
+ }
747
+ }
619
748
  preserve_latest_endpoint_convergence_fields(incoming_entry, latest_entry);
620
749
  }
621
750
  }
@@ -746,6 +875,50 @@ fn preserve_latest_ownership_fields(
746
875
  }
747
876
  }
748
877
 
878
+ fn apply_lock_held_exact_owner_cleanup(incoming: &mut Value, latest: &Value, seed: &Value) {
879
+ apply_lock_held_exact_owner_receiver_cleanup(incoming, latest, seed, None);
880
+ }
881
+
882
+ fn apply_lock_held_exact_owner_receiver_cleanup(
883
+ incoming: &mut Value,
884
+ latest: &Value,
885
+ seed: &Value,
886
+ expected_receiver: Option<&Value>,
887
+ ) {
888
+ // Compare the lock-held disk owner and, when supplied, receiver to the
889
+ // attempt-local grant. A same-owner receiver update is a concurrent
890
+ // winner and must survive cleanup just like a different owner/epoch.
891
+ let latest_owner = latest.get("team_owner");
892
+ let receiver_matches = expected_receiver.is_none_or(|expected| {
893
+ latest.get("leader_receiver") == Some(expected)
894
+ });
895
+ let Some(incoming_obj) = incoming.as_object_mut() else {
896
+ return;
897
+ };
898
+ if latest_owner == Some(seed) && receiver_matches {
899
+ incoming_obj.insert("team_owner".to_string(), Value::Null);
900
+ incoming_obj.insert("leader_receiver".to_string(), Value::Null);
901
+ if let Some(epoch) = latest
902
+ .get("owner_epoch")
903
+ .cloned()
904
+ .or_else(|| latest_owner.and_then(|owner| owner.get("owner_epoch")).cloned())
905
+ {
906
+ incoming_obj.insert("owner_epoch".to_string(), epoch);
907
+ }
908
+ return;
909
+ }
910
+ for key in ["team_owner", "leader_receiver", "owner_epoch"] {
911
+ match latest.get(key) {
912
+ Some(value) => {
913
+ incoming_obj.insert(key.to_string(), value.clone());
914
+ }
915
+ None => {
916
+ incoming_obj.remove(key);
917
+ }
918
+ }
919
+ }
920
+ }
921
+
749
922
  fn preserve_latest_endpoint_convergence_fields(incoming: &mut Value, latest: &Value) {
750
923
  if !latest_has_preferable_endpoint_convergence(incoming, latest) {
751
924
  return;
@@ -812,6 +985,21 @@ fn latest_has_preferable_ownership(
812
985
  && (ownership_attached(latest) || !ownership_attached(incoming))
813
986
  }
814
987
 
988
+ fn latest_team_owner_receiver<'a>(
989
+ latest: &'a Value,
990
+ team_key: Option<&str>,
991
+ ) -> Option<(&'a Value, &'a Value)> {
992
+ let entry = if let Some(team_key) = team_key {
993
+ latest
994
+ .get("teams")
995
+ .and_then(Value::as_object)
996
+ .and_then(|teams| teams.get(team_key))?
997
+ } else {
998
+ latest
999
+ };
1000
+ Some((entry.get("team_owner")?, entry.get("leader_receiver")?))
1001
+ }
1002
+
815
1003
  fn ownership_epoch(state: &Value) -> u64 {
816
1004
  state
817
1005
  .get("owner_epoch")
@@ -2923,6 +3111,8 @@ mod tests {
2923
3111
  &BTreeSet::new(),
2924
3112
  &BTreeSet::new(),
2925
3113
  &BTreeSet::new(),
3114
+ None,
3115
+ None,
2926
3116
  )
2927
3117
  .unwrap();
2928
3118
 
@@ -2957,6 +3147,8 @@ mod tests {
2957
3147
  &BTreeSet::new(),
2958
3148
  &BTreeSet::new(),
2959
3149
  &receiver_updates,
3150
+ None,
3151
+ None,
2960
3152
  )
2961
3153
  .unwrap();
2962
3154
 
@@ -2979,4 +3171,241 @@ mod tests {
2979
3171
  "team-scoped receiver authority must not overwrite a sibling's latest tuple"
2980
3172
  );
2981
3173
  }
3174
+
3175
+ fn seed_owner(epoch: u64) -> Value {
3176
+ json!({
3177
+ "pane_id": "%42",
3178
+ "provider": "pi",
3179
+ "leader_session_uuid": "uuid-seed",
3180
+ "owner_epoch": epoch,
3181
+ "claimed_via": "quick-start"
3182
+ })
3183
+ }
3184
+
3185
+ fn seed_receiver(epoch: u64) -> Value {
3186
+ json!({
3187
+ "mode": "direct_tmux",
3188
+ "status": "attached",
3189
+ "pane_id": "%42",
3190
+ "provider": "pi",
3191
+ "leader_session_uuid": "uuid-seed",
3192
+ "owner_epoch": epoch
3193
+ })
3194
+ }
3195
+
3196
+ fn takeover_owner(epoch: u64) -> Value {
3197
+ json!({
3198
+ "pane_id": "%99",
3199
+ "provider": "codex",
3200
+ "leader_session_uuid": "uuid-other",
3201
+ "owner_epoch": epoch,
3202
+ "claimed_via": "claim-leader"
3203
+ })
3204
+ }
3205
+
3206
+ fn takeover_receiver(epoch: u64) -> Value {
3207
+ json!({
3208
+ "mode": "direct_tmux",
3209
+ "status": "attached",
3210
+ "pane_id": "%99",
3211
+ "provider": "codex",
3212
+ "leader_session_uuid": "uuid-other",
3213
+ "owner_epoch": epoch,
3214
+ "tmux_socket": "/tmp/other"
3215
+ })
3216
+ }
3217
+
3218
+ fn team_state_with_binding(ws: &Path, owner: Value, receiver: Value, epoch: u64) -> Value {
3219
+ json!({
3220
+ "workspace": ws.display().to_string(),
3221
+ "active_team_key": "fresh",
3222
+ "team_key": "fresh",
3223
+ "session_name": "team-fresh",
3224
+ "agents": {"sol": {"status": "running"}},
3225
+ "teams": {
3226
+ "fresh": {
3227
+ "team_key": "fresh",
3228
+ "session_name": "team-fresh",
3229
+ "agents": {"sol": {"status": "running"}},
3230
+ "team_owner": owner,
3231
+ "leader_receiver": receiver,
3232
+ "owner_epoch": epoch
3233
+ }
3234
+ }
3235
+ })
3236
+ }
3237
+
3238
+ fn tombstone_incoming(ws: &Path, epoch: u64) -> Value {
3239
+ let mut incoming = team_state_with_binding(
3240
+ ws,
3241
+ seed_owner(epoch),
3242
+ seed_receiver(epoch),
3243
+ epoch,
3244
+ );
3245
+ incoming["teams"]["fresh"]["team_owner"] = Value::Null;
3246
+ incoming["teams"]["fresh"]["leader_receiver"] = Value::Null;
3247
+ incoming
3248
+ }
3249
+
3250
+ fn assert_fresh_binding(persisted: &Value, owner: &Value, receiver: &Value, epoch: u64) {
3251
+ assert_eq!(&persisted["teams"]["fresh"]["team_owner"], owner);
3252
+ assert_eq!(&persisted["teams"]["fresh"]["leader_receiver"], receiver);
3253
+ assert_eq!(persisted["teams"]["fresh"]["owner_epoch"], json!(epoch));
3254
+ }
3255
+
3256
+ fn persisted_fresh_owner(ws: &Path) -> Value {
3257
+ load_runtime_state(ws).unwrap()["teams"]["fresh"]["team_owner"].clone()
3258
+ }
3259
+
3260
+ #[test]
3261
+ fn exact_seed_cleanup_ignores_pre_lock_cache_and_equality_fast_path() {
3262
+ let ws = temp_ws();
3263
+ save_runtime_state(
3264
+ &ws,
3265
+ &team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
3266
+ )
3267
+ .unwrap();
3268
+ let incoming = load_runtime_state(&ws).unwrap();
3269
+ let seed = persisted_fresh_owner(&ws);
3270
+ save_runtime_state_with_receiver_authority(&ws, &incoming, "fresh", Some(&seed)).unwrap();
3271
+ let persisted = load_runtime_state(&ws).unwrap();
3272
+ assert!(
3273
+ persisted
3274
+ .pointer("/teams/fresh/team_owner")
3275
+ .is_some_and(Value::is_null),
3276
+ "cache/equality hit must not skip exact-seed cleanup; got {}",
3277
+ persisted["teams"]["fresh"]["team_owner"]
3278
+ );
3279
+ assert_eq!(persisted["teams"]["fresh"]["owner_epoch"], json!(1));
3280
+ }
3281
+
3282
+ #[test]
3283
+ fn lock_held_exact_seed_cleanup_clears_matching_owner_without_epoch_bump() {
3284
+ let ws = temp_ws();
3285
+ save_runtime_state(
3286
+ &ws,
3287
+ &team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
3288
+ )
3289
+ .unwrap();
3290
+ let seed = persisted_fresh_owner(&ws);
3291
+ save_runtime_state_with_receiver_authority(
3292
+ &ws,
3293
+ &tombstone_incoming(&ws, 1),
3294
+ "fresh",
3295
+ Some(&seed),
3296
+ )
3297
+ .unwrap();
3298
+ let persisted = load_runtime_state(&ws).unwrap();
3299
+ assert!(
3300
+ persisted
3301
+ .pointer("/teams/fresh/team_owner")
3302
+ .is_some_and(Value::is_null),
3303
+ "exact seed must be cleared; got {}",
3304
+ persisted["teams"]["fresh"]["team_owner"]
3305
+ );
3306
+ assert!(
3307
+ persisted
3308
+ .pointer("/teams/fresh/leader_receiver")
3309
+ .is_some_and(Value::is_null)
3310
+ );
3311
+ assert_eq!(
3312
+ persisted["teams"]["fresh"]["owner_epoch"],
3313
+ json!(1),
3314
+ "cleanup must not bump epoch past a same-epoch concurrent owner"
3315
+ );
3316
+ }
3317
+
3318
+ #[test]
3319
+ fn lock_held_exact_seed_cleanup_preserves_equal_epoch_concurrent_binding() {
3320
+ let ws = temp_ws();
3321
+ save_runtime_state(
3322
+ &ws,
3323
+ &team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
3324
+ )
3325
+ .unwrap();
3326
+ let seed = persisted_fresh_owner(&ws);
3327
+ save_runtime_state_with_receiver_authority(
3328
+ &ws,
3329
+ &team_state_with_binding(&ws, takeover_owner(1), takeover_receiver(1), 1),
3330
+ "fresh",
3331
+ None,
3332
+ )
3333
+ .unwrap();
3334
+ let expected = load_runtime_state(&ws).unwrap();
3335
+ assert_fresh_binding(&expected, &takeover_owner(1), &takeover_receiver(1), 1);
3336
+ save_runtime_state_with_receiver_authority(
3337
+ &ws,
3338
+ &tombstone_incoming(&ws, 1),
3339
+ "fresh",
3340
+ Some(&seed),
3341
+ )
3342
+ .unwrap();
3343
+ assert_fresh_binding(
3344
+ &load_runtime_state(&ws).unwrap(),
3345
+ &expected["teams"]["fresh"]["team_owner"],
3346
+ &expected["teams"]["fresh"]["leader_receiver"],
3347
+ 1,
3348
+ );
3349
+ }
3350
+
3351
+ #[test]
3352
+ fn lock_held_exact_seed_receiver_cleanup_preserves_same_owner_receiver_update() {
3353
+ let ws = temp_ws();
3354
+ save_runtime_state(
3355
+ &ws,
3356
+ &team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
3357
+ )
3358
+ .unwrap();
3359
+ let seed = persisted_fresh_owner(&ws);
3360
+ let seeded_receiver = load_runtime_state(&ws).unwrap()["teams"]["fresh"]
3361
+ ["leader_receiver"]
3362
+ .clone();
3363
+ let mut winner = team_state_with_binding(&ws, seed_owner(1), takeover_receiver(1), 1);
3364
+ winner["teams"]["fresh"]["team_owner"] = seed.clone();
3365
+ save_runtime_state_with_receiver_authority(&ws, &winner, "fresh", None).unwrap();
3366
+ save_runtime_state_with_exact_owner_receiver_cleanup(
3367
+ &ws,
3368
+ &tombstone_incoming(&ws, 1),
3369
+ "fresh",
3370
+ &seed,
3371
+ &seeded_receiver,
3372
+ )
3373
+ .unwrap();
3374
+ assert_fresh_binding(
3375
+ &load_runtime_state(&ws).unwrap(),
3376
+ &seed,
3377
+ &takeover_receiver(1),
3378
+ 1,
3379
+ );
3380
+ }
3381
+
3382
+ #[test]
3383
+ fn lock_held_exact_seed_cleanup_preserves_epoch2_takeover_binding() {
3384
+ let ws = temp_ws();
3385
+ save_runtime_state(
3386
+ &ws,
3387
+ &team_state_with_binding(&ws, seed_owner(1), seed_receiver(1), 1),
3388
+ )
3389
+ .unwrap();
3390
+ let seed = persisted_fresh_owner(&ws);
3391
+ save_runtime_state(
3392
+ &ws,
3393
+ &team_state_with_binding(&ws, takeover_owner(2), takeover_receiver(2), 2),
3394
+ )
3395
+ .unwrap();
3396
+ let expected = load_runtime_state(&ws).unwrap();
3397
+ save_runtime_state_with_receiver_authority(
3398
+ &ws,
3399
+ &tombstone_incoming(&ws, 1),
3400
+ "fresh",
3401
+ Some(&seed),
3402
+ )
3403
+ .unwrap();
3404
+ assert_fresh_binding(
3405
+ &load_runtime_state(&ws).unwrap(),
3406
+ &expected["teams"]["fresh"]["team_owner"],
3407
+ &expected["teams"]["fresh"]["leader_receiver"],
3408
+ 2,
3409
+ );
3410
+ }
2982
3411
  }
@@ -48,6 +48,10 @@ use super::persist::{
48
48
  save_runtime_state_with_lifecycle_topology_authority as helper_write_root_with_lifecycle_topology_authority,
49
49
  save_runtime_state_with_lifecycle_topology_authority_and_capture_backfill_skip as helper_write_root_with_lifecycle_topology_authority_and_capture_backfill_skip,
50
50
  save_runtime_state_with_receiver_authority as helper_write_root_with_receiver_authority,
51
+ save_runtime_state_with_receiver_authority_and_expected
52
+ as helper_write_root_with_receiver_authority_and_expected,
53
+ save_runtime_state_with_exact_owner_receiver_cleanup
54
+ as helper_write_root_with_exact_owner_receiver_cleanup,
51
55
  save_runtime_state_with_team_tombstone_lifecycle_topology_authority as helper_write_root_with_team_tombstone_lifecycle_topology_authority,
52
56
  save_runtime_state_with_team_tombstoned_agents as helper_write_root_with_team_tombstoned_agents,
53
57
  save_runtime_state_without_migrations as helper_write_root_without_migrations,
@@ -206,6 +210,20 @@ pub enum StateWriteIntent<'a> {
206
210
  ClaimLeader {
207
211
  team_key: &'a str,
208
212
  },
213
+ ClaimLeaderFreshCaller {
214
+ team_key: &'a str,
215
+ expected_owner: &'a Value,
216
+ expected_receiver: &'a Value,
217
+ },
218
+ ClearExactTeamOwner {
219
+ team_key: &'a str,
220
+ seed: &'a Value,
221
+ },
222
+ ClearExactTeamOwnerAndReceiver {
223
+ team_key: &'a str,
224
+ seed: &'a Value,
225
+ expected_receiver: &'a Value,
226
+ },
209
227
  LeaderBindingRestoreNonTargetTeams {
210
228
  target_team_key: &'a str,
211
229
  },
@@ -383,15 +401,40 @@ fn route_direct(
383
401
  // scoped preserve-claim-fields variant at :1702 uses the team-tombstoned
384
402
  // agents helper.
385
403
  StateWriteIntent::ClaimLeader { team_key } => {
386
- helper_write_root_with_receiver_authority(workspace, state, team_key)
404
+ helper_write_root_with_receiver_authority(workspace, state, team_key, None)
387
405
  }
406
+ StateWriteIntent::ClaimLeaderFreshCaller {
407
+ team_key,
408
+ expected_owner,
409
+ expected_receiver,
410
+ } => helper_write_root_with_receiver_authority_and_expected(
411
+ workspace,
412
+ state,
413
+ team_key,
414
+ expected_owner,
415
+ expected_receiver,
416
+ ),
417
+ StateWriteIntent::ClearExactTeamOwner { team_key, seed } => {
418
+ helper_write_root_with_receiver_authority(workspace, state, team_key, Some(seed))
419
+ }
420
+ StateWriteIntent::ClearExactTeamOwnerAndReceiver {
421
+ team_key,
422
+ seed,
423
+ expected_receiver,
424
+ } => helper_write_root_with_exact_owner_receiver_cleanup(
425
+ workspace,
426
+ state,
427
+ team_key,
428
+ seed,
429
+ expected_receiver,
430
+ ),
388
431
  StateWriteIntent::LeaderBindingRestoreNonTargetTeams { .. } => {
389
432
  helper_write_root_without_migrations(workspace, state)
390
433
  }
391
434
  // LeaderStartBinding -> managed/exec/external all root-save at
392
435
  // leader/start.rs:795/903/946.
393
436
  StateWriteIntent::LeaderStartBinding { team_key, .. } => {
394
- helper_write_root_with_receiver_authority(workspace, state, team_key)
437
+ helper_write_root_with_receiver_authority(workspace, state, team_key, None)
395
438
  }
396
439
  // CoordinatorTick dispatches to the existing scoped helper
397
440
  // (`save_team_scoped_state`) preserving coordinator/tick.rs:427.