@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +19 -13
- package/crates/team-agent/src/cli/emit.rs +6 -3
- package/crates/team-agent/src/cli/mod.rs +151 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/compiler.rs +14 -1
- package/crates/team-agent/src/layout/worker_env.rs +366 -0
- package/crates/team-agent/src/leader/lease.rs +334 -53
- package/crates/team-agent/src/leader/registry.rs +177 -108
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
- package/crates/team-agent/src/messaging/delivery.rs +226 -37
- package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
- package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
- package/crates/team-agent/src/model/spec.rs +81 -7
- package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
- package/crates/team-agent/src/state/persist.rs +433 -4
- package/crates/team-agent/src/state/repository.rs +45 -2
- package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
- package/crates/team-agent/src/tmux_backend.rs +274 -26
- package/crates/team-agent/src/transport/test_support.rs +18 -3
- package/crates/team-agent/src/transport.rs +99 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +3 -0
- package/skills/team-agent/command-coverage.json +21 -0
- 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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|