@team-agent/installer 0.4.11 → 0.5.0

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 (72) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +62 -4
  6. package/crates/team-agent/src/compiler.rs +11 -20
  7. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  9. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  10. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  11. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  12. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  13. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  14. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  15. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  16. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  17. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  18. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  19. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  20. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  21. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  22. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  23. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  24. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  26. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  27. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  28. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  29. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  30. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  31. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  32. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  33. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
  34. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  35. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  36. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  37. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  38. package/crates/team-agent/src/messaging/activity.rs +43 -18
  39. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  40. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  41. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  42. package/crates/team-agent/src/messaging/results.rs +34 -9
  43. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  44. package/crates/team-agent/src/model/spec.rs +10 -6
  45. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  46. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  47. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  48. package/crates/team-agent/src/provider/classify.rs +23 -48
  49. package/crates/team-agent/src/provider/command.rs +16 -7
  50. package/crates/team-agent/src/provider/faults.rs +93 -53
  51. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  52. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  53. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  54. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  55. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  56. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  57. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  58. package/crates/team-agent/src/provider/types.rs +33 -1
  59. package/crates/team-agent/src/provider/wire.rs +171 -1
  60. package/crates/team-agent/src/state/identity.rs +242 -57
  61. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  62. package/crates/team-agent/src/state/mod.rs +5 -1
  63. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  64. package/crates/team-agent/src/state/ownership.rs +12 -11
  65. package/crates/team-agent/src/state/paths.rs +13 -6
  66. package/crates/team-agent/src/state/persist.rs +671 -128
  67. package/crates/team-agent/src/state/projection.rs +240 -49
  68. package/crates/team-agent/src/state/selector.rs +11 -5
  69. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  70. package/crates/team-agent/src/tmux_backend.rs +42 -5
  71. package/crates/team-agent/src/transport/test_support.rs +55 -6
  72. package/package.json +4 -4
@@ -76,23 +76,47 @@ pub fn identity_machine_fingerprint(state: &Value, env: &dyn IdentityEnv) -> Str
76
76
 
77
77
  /// `_identity_workspace_abspath`(`state.py:264`):按 workspace→team_dir→spec_path→fallback
78
78
  /// 顺序派生 workspace 绝对路径(各分支先 `resolve()` 再做 parent 导航)。
79
- pub fn identity_workspace_abspath(state: &Value, env: &dyn IdentityEnv, workspace: Option<&Path>) -> String {
80
- if let Some(ws) = state.get("workspace").and_then(Value::as_str).filter(|s| !s.is_empty()) {
79
+ pub fn identity_workspace_abspath(
80
+ state: &Value,
81
+ env: &dyn IdentityEnv,
82
+ workspace: Option<&Path>,
83
+ ) -> String {
84
+ if let Some(ws) = state
85
+ .get("workspace")
86
+ .and_then(Value::as_str)
87
+ .filter(|s| !s.is_empty())
88
+ {
81
89
  return path_str(&realpath_like(Path::new(ws)));
82
90
  }
83
- if let Some(td) = state.get("team_dir").and_then(Value::as_str).filter(|s| !s.is_empty()) {
91
+ if let Some(td) = state
92
+ .get("team_dir")
93
+ .and_then(Value::as_str)
94
+ .filter(|s| !s.is_empty())
95
+ {
84
96
  // resolve().parent.parent
85
97
  let r = realpath_like(Path::new(td));
86
- let pp = r.parent().and_then(Path::parent).map_or_else(|| r.clone(), Path::to_path_buf);
98
+ let pp = r
99
+ .parent()
100
+ .and_then(Path::parent)
101
+ .map_or_else(|| r.clone(), Path::to_path_buf);
87
102
  return path_str(&pp);
88
103
  }
89
- if let Some(sp) = state.get("spec_path").and_then(Value::as_str).filter(|s| !s.is_empty()) {
104
+ if let Some(sp) = state
105
+ .get("spec_path")
106
+ .and_then(Value::as_str)
107
+ .filter(|s| !s.is_empty())
108
+ {
90
109
  let r = realpath_like(Path::new(sp));
91
110
  let parent = r.parent();
92
111
  let grandparent = parent.and_then(Path::parent);
93
112
  // grandparent.name == ".team" ? grandparent.parent : parent
94
- let result = if grandparent.and_then(Path::file_name).is_some_and(|n| n == std::ffi::OsStr::new(".team")) {
95
- grandparent.and_then(Path::parent).map_or_else(|| r.clone(), Path::to_path_buf)
113
+ let result = if grandparent
114
+ .and_then(Path::file_name)
115
+ .is_some_and(|n| n == std::ffi::OsStr::new(".team"))
116
+ {
117
+ grandparent
118
+ .and_then(Path::parent)
119
+ .map_or_else(|| r.clone(), Path::to_path_buf)
96
120
  } else {
97
121
  parent.map_or_else(|| r.clone(), Path::to_path_buf)
98
122
  };
@@ -146,12 +170,18 @@ pub fn migrate_team_identity(
146
170
  for key in ["team_owner", "leader_receiver"] {
147
171
  // record 是 dict 且其 leader_session_uuid 为空/缺 → 填。
148
172
  let needs = match state.get(key).and_then(Value::as_object) {
149
- Some(r) => r.get("leader_session_uuid").and_then(Value::as_str).is_none_or(str::is_empty),
173
+ Some(r) => r
174
+ .get("leader_session_uuid")
175
+ .and_then(Value::as_str)
176
+ .is_none_or(str::is_empty),
150
177
  None => false,
151
178
  };
152
179
  if needs {
153
180
  if let Some(obj) = state.get_mut(key).and_then(Value::as_object_mut) {
154
- obj.insert("leader_session_uuid".to_string(), Value::String(leader_uuid.clone()));
181
+ obj.insert(
182
+ "leader_session_uuid".to_string(),
183
+ Value::String(leader_uuid.clone()),
184
+ );
155
185
  changed = true;
156
186
  }
157
187
  }
@@ -160,7 +190,11 @@ pub fn migrate_team_identity(
160
190
  }
161
191
 
162
192
  /// `_migrate_state_identity`(`state.py:306`):顶层(有 session_name 时)+ 每个 team 子状态迁移。
163
- pub fn migrate_state_identity(state: &mut Value, env: &dyn IdentityEnv, workspace: &Path) -> Result<bool, ModelError> {
193
+ pub fn migrate_state_identity(
194
+ state: &mut Value,
195
+ env: &dyn IdentityEnv,
196
+ workspace: &Path,
197
+ ) -> Result<bool, ModelError> {
164
198
  let mut changed = false;
165
199
  if state.get("session_name").is_some_and(json_truthy) {
166
200
  changed = migrate_team_identity(state, env, Some(workspace), None)?;
@@ -172,7 +206,11 @@ pub fn migrate_state_identity(state: &mut Value, env: &dyn IdentityEnv, workspac
172
206
  .unwrap_or_default();
173
207
  for tid in team_keys {
174
208
  // 取出 team_state &mut,就地迁移(team_id = key)。
175
- if let Some(team_state) = state.get_mut("teams").and_then(Value::as_object_mut).and_then(|t| t.get_mut(&tid)) {
209
+ if let Some(team_state) = state
210
+ .get_mut("teams")
211
+ .and_then(Value::as_object_mut)
212
+ .and_then(|t| t.get_mut(&tid))
213
+ {
176
214
  if team_state.is_object() {
177
215
  let c = migrate_team_identity(team_state, env, Some(workspace), Some(&tid))?;
178
216
  changed = c || changed;
@@ -277,8 +315,7 @@ pub fn populate_team_owner_from_env(
277
315
  // Net behaviour preserved: still writes top-level (legacy dual-write);
278
316
  // 3b/3c/3d will remove the read-side projection promote, persist
279
317
  // copy-back, and finally the top-level write.
280
- let record = crate::state::ownership::OwnershipWrite::new()
281
- .with_team_owner(owner.clone());
318
+ let record = crate::state::ownership::OwnershipWrite::new().with_team_owner(owner.clone());
282
319
  crate::state::ownership::write_owner(state, &key, record);
283
320
  Ok(Some(owner))
284
321
  }
@@ -297,9 +334,17 @@ pub fn apply_first_time_leader_binding(
297
334
  now_iso: &str,
298
335
  command_looks_usable: impl Fn(&str, &str) -> bool,
299
336
  ) -> Value {
300
- let command = pane_info.get("pane_current_command").and_then(Value::as_str).unwrap_or("");
337
+ let command = pane_info
338
+ .get("pane_current_command")
339
+ .and_then(Value::as_str)
340
+ .unwrap_or("");
301
341
  // str(receiver.get("provider") or "")
302
- let provider = receiver.get("provider").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("").to_string();
342
+ let provider = receiver
343
+ .get("provider")
344
+ .and_then(Value::as_str)
345
+ .filter(|s| !s.is_empty())
346
+ .unwrap_or("")
347
+ .to_string();
303
348
  if !command_looks_usable(command, &provider) {
304
349
  return json!({
305
350
  "ok": false,
@@ -331,8 +376,14 @@ pub fn apply_first_time_leader_binding(
331
376
  });
332
377
  }
333
378
  // receiver.update({leader_session_uuid, machine_fingerprint, owner_epoch: 0})
334
- let id_uuid = identity.get("leader_session_uuid").cloned().unwrap_or(Value::Null);
335
- let id_fp = identity.get("machine_fingerprint").cloned().unwrap_or(Value::Null);
379
+ let id_uuid = identity
380
+ .get("leader_session_uuid")
381
+ .cloned()
382
+ .unwrap_or(Value::Null);
383
+ let id_fp = identity
384
+ .get("machine_fingerprint")
385
+ .cloned()
386
+ .unwrap_or(Value::Null);
336
387
  if let Some(r) = receiver.as_object_mut() {
337
388
  r.insert("leader_session_uuid".to_string(), id_uuid.clone());
338
389
  r.insert("machine_fingerprint".to_string(), id_fp.clone());
@@ -363,12 +414,30 @@ pub fn apply_first_time_leader_binding(
363
414
  /// `leader_env_exports`(`state.py:469`):receiver + identity → 6 个 leader env 导出(插入序)。
364
415
  pub fn leader_env_exports(receiver: &Value, identity: &Value) -> Vec<(String, String)> {
365
416
  vec![
366
- ("TEAM_AGENT_LEADER_PANE_ID".to_string(), str_or_empty(receiver.get("pane_id"))),
367
- ("TEAM_AGENT_LEADER_PROVIDER".to_string(), str_or_empty(receiver.get("provider"))),
368
- ("TEAM_AGENT_LEADER_SESSION_UUID".to_string(), str_or_empty(identity.get("leader_session_uuid"))),
369
- ("TEAM_AGENT_MACHINE_FINGERPRINT".to_string(), str_or_empty(identity.get("machine_fingerprint"))),
370
- ("TEAM_AGENT_WORKSPACE".to_string(), str_or_empty(identity.get("workspace_abspath"))),
371
- ("TEAM_AGENT_TEAM_ID".to_string(), str_or_empty(identity.get("team_id"))),
417
+ (
418
+ "TEAM_AGENT_LEADER_PANE_ID".to_string(),
419
+ str_or_empty(receiver.get("pane_id")),
420
+ ),
421
+ (
422
+ "TEAM_AGENT_LEADER_PROVIDER".to_string(),
423
+ str_or_empty(receiver.get("provider")),
424
+ ),
425
+ (
426
+ "TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
427
+ str_or_empty(identity.get("leader_session_uuid")),
428
+ ),
429
+ (
430
+ "TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
431
+ str_or_empty(identity.get("machine_fingerprint")),
432
+ ),
433
+ (
434
+ "TEAM_AGENT_WORKSPACE".to_string(),
435
+ str_or_empty(identity.get("workspace_abspath")),
436
+ ),
437
+ (
438
+ "TEAM_AGENT_TEAM_ID".to_string(),
439
+ str_or_empty(identity.get("team_id")),
440
+ ),
372
441
  ]
373
442
  }
374
443
 
@@ -393,7 +462,9 @@ pub fn validate_leader_uuid_from_targets(receiver: &Value, targets: &Value) -> V
393
462
  .and_then(|arr| arr.iter().find(|item| item.get("pane_id") == pane_id));
394
463
  match target {
395
464
  Some(t) => json!({ "ok": true, "pane": t }),
396
- None => json!({ "ok": false, "reason": "leader_pane_missing", "error": "tmux pane does not exist" }),
465
+ None => {
466
+ json!({ "ok": false, "reason": "leader_pane_missing", "error": "tmux pane does not exist" })
467
+ }
397
468
  }
398
469
  }
399
470
 
@@ -401,12 +472,19 @@ pub fn validate_leader_uuid_from_targets(receiver: &Value, targets: &Value) -> V
401
472
 
402
473
  /// `str(v or "")`(string 域):truthy 字符串 → 自身,否则 `""`。
403
474
  fn str_or_empty(v: Option<&Value>) -> String {
404
- v.and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("").to_string()
475
+ v.and_then(Value::as_str)
476
+ .filter(|s| !s.is_empty())
477
+ .unwrap_or("")
478
+ .to_string()
405
479
  }
406
480
 
407
481
  /// Python `repr()` of a `str`(与 `model::spec::py_repr_str` 同实现)。
408
482
  fn py_repr_str(s: &str) -> String {
409
- let quote = if s.contains('\'') && !s.contains('"') { '"' } else { '\'' };
483
+ let quote = if s.contains('\'') && !s.contains('"') {
484
+ '"'
485
+ } else {
486
+ '\''
487
+ };
410
488
  let mut out = String::new();
411
489
  out.push(quote);
412
490
  for c in s.chars() {
@@ -443,10 +521,18 @@ mod tests {
443
521
  }
444
522
  }
445
523
  fn env(pairs: &[(&str, &str)]) -> MockEnv {
446
- MockEnv(pairs.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect())
524
+ MockEnv(
525
+ pairs
526
+ .iter()
527
+ .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
528
+ .collect(),
529
+ )
447
530
  }
448
531
  fn same_repr(a: &Value, b: &Value) {
449
- assert_eq!(serde_json::to_string(a).unwrap(), serde_json::to_string(b).unwrap());
532
+ assert_eq!(
533
+ serde_json::to_string(a).unwrap(),
534
+ serde_json::to_string(b).unwrap()
535
+ );
450
536
  }
451
537
  fn temp_ws() -> PathBuf {
452
538
  let n = SEQ.fetch_add(1, Ordering::Relaxed);
@@ -488,8 +574,17 @@ mod tests {
488
574
  assert_eq!(identity_os_user(&env(&[])), "");
489
575
  // state record fp 优先 env。
490
576
  let s = json!({"team_owner": {"machine_fingerprint": "from-owner"}});
491
- assert_eq!(identity_machine_fingerprint(&s, &env(&[("TEAM_AGENT_MACHINE_FINGERPRINT", "envfp")])), "from-owner");
492
- assert_eq!(identity_machine_fingerprint(&json!({}), &env(&[("TEAM_AGENT_MACHINE_FINGERPRINT", "envfp")])), "envfp");
577
+ assert_eq!(
578
+ identity_machine_fingerprint(&s, &env(&[("TEAM_AGENT_MACHINE_FINGERPRINT", "envfp")])),
579
+ "from-owner"
580
+ );
581
+ assert_eq!(
582
+ identity_machine_fingerprint(
583
+ &json!({}),
584
+ &env(&[("TEAM_AGENT_MACHINE_FINGERPRINT", "envfp")])
585
+ ),
586
+ "envfp"
587
+ );
493
588
  }
494
589
 
495
590
  #[test]
@@ -502,7 +597,8 @@ mod tests {
502
597
  ("TEAM_AGENT_LEADER_PROVIDER", "codex"),
503
598
  ];
504
599
  // derived(golden uuid 由 Python 真相源算出;/ws/proj 两机皆不存在 → realpath 词法等价)。
505
- let cid = caller_identity_from_env(Some(&json!({})), &env(&base), Some("teamA"), None).unwrap();
600
+ let cid =
601
+ caller_identity_from_env(Some(&json!({})), &env(&base), Some("teamA"), None).unwrap();
506
602
  assert_eq!(cid.leader_session_uuid, "379f5e361ec429edabea7022391e8ca8");
507
603
  assert_eq!(cid.leader_session_uuid_source, "derived");
508
604
  assert_eq!(cid.pane_id, "%5");
@@ -511,12 +607,14 @@ mod tests {
511
607
 
512
608
  let mut env_v: Vec<(&str, &str)> = base.to_vec();
513
609
  env_v.push(("TEAM_AGENT_LEADER_SESSION_UUID", "envuuid123"));
514
- let cid_env = caller_identity_from_env(Some(&json!({})), &env(&env_v), Some("teamA"), None).unwrap();
610
+ let cid_env =
611
+ caller_identity_from_env(Some(&json!({})), &env(&env_v), Some("teamA"), None).unwrap();
515
612
  assert_eq!(cid_env.leader_session_uuid, "envuuid123");
516
613
  assert_eq!(cid_env.leader_session_uuid_source, "env");
517
614
 
518
615
  env_v.push(("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE", "ovr999"));
519
- let cid_ovr = caller_identity_from_env(Some(&json!({})), &env(&env_v), Some("teamA"), None).unwrap();
616
+ let cid_ovr =
617
+ caller_identity_from_env(Some(&json!({})), &env(&env_v), Some("teamA"), None).unwrap();
520
618
  assert_eq!(cid_ovr.leader_session_uuid, "ovr999");
521
619
  assert_eq!(cid_ovr.leader_session_uuid_source, "explicit-override");
522
620
  }
@@ -525,7 +623,10 @@ mod tests {
525
623
  fn pane_id_falls_back_to_tmux_pane() {
526
624
  let cid = caller_identity_from_env(
527
625
  Some(&json!({})),
528
- &env(&[("TMUX_PANE", "%tmux"), ("TEAM_AGENT_LEADER_SESSION_UUID", "u")]),
626
+ &env(&[
627
+ ("TMUX_PANE", "%tmux"),
628
+ ("TEAM_AGENT_LEADER_SESSION_UUID", "u"),
629
+ ]),
529
630
  Some("t"),
530
631
  None,
531
632
  )
@@ -537,14 +638,23 @@ mod tests {
537
638
  fn migrate_team_identity_fills_then_idempotent() {
538
639
  let e = env(&[("USER", "u"), ("TEAM_AGENT_MACHINE_FINGERPRINT", "fp")]);
539
640
  let mut state = json!({"team_owner": {"pane_id": "%1"}, "session_name": "s"});
540
- let changed = migrate_team_identity(&mut state, &e, Some(Path::new("/ws")), Some("tid")).unwrap();
641
+ let changed =
642
+ migrate_team_identity(&mut state, &e, Some(Path::new("/ws")), Some("tid")).unwrap();
541
643
  assert!(changed);
542
- let uuid = state["team_owner"]["leader_session_uuid"].as_str().unwrap().to_string();
644
+ let uuid = state["team_owner"]["leader_session_uuid"]
645
+ .as_str()
646
+ .unwrap()
647
+ .to_string();
543
648
  // 与 derive 公式一致(workspace=/ws 不存在 → 词法 "/ws")。
544
- let expected = LeaderSessionUuid::derive("fp", "/ws", "u", "tid").unwrap().as_str().to_string();
649
+ let expected = LeaderSessionUuid::derive("fp", "/ws", "u", "tid")
650
+ .unwrap()
651
+ .as_str()
652
+ .to_string();
545
653
  assert_eq!(uuid, expected);
546
654
  // 二次调用:已有非空 uuid → 不改。
547
- assert!(!migrate_team_identity(&mut state, &e, Some(Path::new("/ws")), Some("tid")).unwrap());
655
+ assert!(
656
+ !migrate_team_identity(&mut state, &e, Some(Path::new("/ws")), Some("tid")).unwrap()
657
+ );
548
658
  }
549
659
 
550
660
  #[test]
@@ -557,8 +667,20 @@ mod tests {
557
667
  });
558
668
  let changed = migrate_state_identity(&mut state, &e, Path::new("/ws")).unwrap();
559
669
  assert!(changed);
560
- assert!(state["team_owner"]["leader_session_uuid"].as_str().unwrap().len() == 32);
561
- assert!(state["teams"]["t1"]["team_owner"]["leader_session_uuid"].as_str().unwrap().len() == 32);
670
+ assert!(
671
+ state["team_owner"]["leader_session_uuid"]
672
+ .as_str()
673
+ .unwrap()
674
+ .len()
675
+ == 32
676
+ );
677
+ assert!(
678
+ state["teams"]["t1"]["team_owner"]["leader_session_uuid"]
679
+ .as_str()
680
+ .unwrap()
681
+ .len()
682
+ == 32
683
+ );
562
684
  // t1 的 uuid 用 team_id="t1" 派生,异于顶层(team_id=team_state_key=session "s")。
563
685
  assert_ne!(
564
686
  state["team_owner"]["leader_session_uuid"],
@@ -581,7 +703,9 @@ mod tests {
581
703
  ("TEAM_AGENT_LEADER_SESSION_UUID", "u1"),
582
704
  ]);
583
705
  let mut state = json!({});
584
- let owner = populate_team_owner_from_env(&mut state, "autopopulate", &e_with_pane, "TS").unwrap().unwrap();
706
+ let owner = populate_team_owner_from_env(&mut state, "autopopulate", &e_with_pane, "TS")
707
+ .unwrap()
708
+ .unwrap();
585
709
  same_repr(
586
710
  &owner,
587
711
  &json!({"pane_id": "%9", "provider": "codex", "machine_fingerprint": "fp",
@@ -596,14 +720,20 @@ mod tests {
596
720
 
597
721
  // 无 pane_id → None,不写 owner。
598
722
  let mut state2 = json!({});
599
- assert!(populate_team_owner_from_env(&mut state2, "x", &env(&[]), "TS").unwrap().is_none());
723
+ assert!(
724
+ populate_team_owner_from_env(&mut state2, "x", &env(&[]), "TS")
725
+ .unwrap()
726
+ .is_none()
727
+ );
600
728
  let team_key2 = crate::state::projection::team_state_key(&state2);
601
729
  assert!(crate::state::ownership::read_owner_value(&state2, &team_key2).is_none());
602
730
 
603
731
  // 已有 team_owner(缺 uuid)→ 迁移补 uuid 后返回。Legacy top-level shape
604
732
  // still recognized (read precedence supports the migration window).
605
733
  let mut state3 = json!({"team_owner": {"pane_id": "%1", "machine_fingerprint": "fp"}, "session_name": "s"});
606
- let o3 = populate_team_owner_from_env(&mut state3, "x", &env(&[("USER", "u")]), "TS").unwrap().unwrap();
734
+ let o3 = populate_team_owner_from_env(&mut state3, "x", &env(&[("USER", "u")]), "TS")
735
+ .unwrap()
736
+ .unwrap();
607
737
  assert_eq!(o3["leader_session_uuid"].as_str().unwrap().len(), 32);
608
738
  }
609
739
 
@@ -620,9 +750,22 @@ mod tests {
620
750
  let mut state = json!({});
621
751
  let mut receiver = json!({"pane_id": "%9", "provider": "codex"});
622
752
  let identity = json!({"leader_session_uuid": "uuidX", "machine_fingerprint": "fpX"});
623
- let pane = json!({"pane_current_command": "codex", "pane_current_path": ws.to_string_lossy()});
624
- let r = apply_first_time_leader_binding(&ws, &mut state, &mut receiver, &pane, &identity, "claim", now, |_c, _p| true);
625
- same_repr(&r, &json!({"ok": true, "pane": pane, "warning": null, "first_time": true}));
753
+ let pane =
754
+ json!({"pane_current_command": "codex", "pane_current_path": ws.to_string_lossy()});
755
+ let r = apply_first_time_leader_binding(
756
+ &ws,
757
+ &mut state,
758
+ &mut receiver,
759
+ &pane,
760
+ &identity,
761
+ "claim",
762
+ now,
763
+ |_c, _p| true,
764
+ );
765
+ same_repr(
766
+ &r,
767
+ &json!({"ok": true, "pane": pane, "warning": null, "first_time": true}),
768
+ );
626
769
  let team_key = crate::state::projection::team_state_key(&state);
627
770
  let team_owner = crate::state::ownership::read_owner_value(&state, &team_key)
628
771
  .expect("Stage 3d: team_owner reachable via repository");
@@ -648,11 +791,24 @@ mod tests {
648
791
  // wrong command(闭包返回 false)。
649
792
  let mut s1 = json!({});
650
793
  let mut r1 = json!({"pane_id": "%9", "provider": "codex"});
651
- let pane1 = json!({"pane_current_command": "bash", "pane_current_path": ws.to_string_lossy()});
652
- let res1 = apply_first_time_leader_binding(&ws, &mut s1, &mut r1, &pane1, &identity, "claim", now, |_c, _p| false);
794
+ let pane1 =
795
+ json!({"pane_current_command": "bash", "pane_current_path": ws.to_string_lossy()});
796
+ let res1 = apply_first_time_leader_binding(
797
+ &ws,
798
+ &mut s1,
799
+ &mut r1,
800
+ &pane1,
801
+ &identity,
802
+ "claim",
803
+ now,
804
+ |_c, _p| false,
805
+ );
653
806
  assert_eq!(res1["ok"], json!(false));
654
807
  assert_eq!(res1["reason"], json!("leader_pane_wrong_command"));
655
- assert_eq!(res1["error"], json!("pane command 'bash' is not a leader host"));
808
+ assert_eq!(
809
+ res1["error"],
810
+ json!("pane command 'bash' is not a leader host")
811
+ );
656
812
  assert_eq!(res1["pane"], pane1);
657
813
  assert!(s1.get("team_owner").is_none(), "拒绝不得写 state");
658
814
 
@@ -660,12 +816,23 @@ mod tests {
660
816
  let mut s2 = json!({});
661
817
  let mut r2 = json!({"pane_id": "%9", "provider": "codex"});
662
818
  let pane2 = json!({"pane_current_command": "codex", "pane_current_path": "/nowhere/else"});
663
- let res2 = apply_first_time_leader_binding(&ws, &mut s2, &mut r2, &pane2, &identity, "claim", now, |_c, _p| true);
819
+ let res2 = apply_first_time_leader_binding(
820
+ &ws,
821
+ &mut s2,
822
+ &mut r2,
823
+ &pane2,
824
+ &identity,
825
+ "claim",
826
+ now,
827
+ |_c, _p| true,
828
+ );
664
829
  assert_eq!(res2["reason"], json!("leader_pane_wrong_workspace"));
665
830
  let ws_repr = py_repr_str(&path_str(&realpath_like(&ws)));
666
831
  assert_eq!(
667
832
  res2["error"],
668
- json!(format!("pane cwd '/nowhere/else' does not match workspace {ws_repr}"))
833
+ json!(format!(
834
+ "pane cwd '/nowhere/else' does not match workspace {ws_repr}"
835
+ ))
669
836
  );
670
837
  assert!(s2.get("team_owner").is_none());
671
838
  }
@@ -680,9 +847,18 @@ mod tests {
680
847
  exports,
681
848
  vec![
682
849
  ("TEAM_AGENT_LEADER_PANE_ID".to_string(), "%1".to_string()),
683
- ("TEAM_AGENT_LEADER_PROVIDER".to_string(), "codex".to_string()),
684
- ("TEAM_AGENT_LEADER_SESSION_UUID".to_string(), "u1".to_string()),
685
- ("TEAM_AGENT_MACHINE_FINGERPRINT".to_string(), "f1".to_string()),
850
+ (
851
+ "TEAM_AGENT_LEADER_PROVIDER".to_string(),
852
+ "codex".to_string()
853
+ ),
854
+ (
855
+ "TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
856
+ "u1".to_string()
857
+ ),
858
+ (
859
+ "TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
860
+ "f1".to_string()
861
+ ),
686
862
  ("TEAM_AGENT_WORKSPACE".to_string(), "/w".to_string()),
687
863
  ("TEAM_AGENT_TEAM_ID".to_string(), "t".to_string()),
688
864
  ]
@@ -691,13 +867,22 @@ mod tests {
691
867
 
692
868
  #[test]
693
869
  fn validate_leader_uuid_from_targets_cases() {
694
- same_repr(&validate_leader_uuid_from_targets(&json!({"provider": "fake"}), &json!({})), &json!({"ok": true}));
695
870
  same_repr(
696
- &validate_leader_uuid_from_targets(&json!({"provider": "codex"}), &json!({"ok": false, "error": "boom"})),
871
+ &validate_leader_uuid_from_targets(&json!({"provider": "fake"}), &json!({})),
872
+ &json!({"ok": true}),
873
+ );
874
+ same_repr(
875
+ &validate_leader_uuid_from_targets(
876
+ &json!({"provider": "codex"}),
877
+ &json!({"ok": false, "error": "boom"}),
878
+ ),
697
879
  &json!({"ok": false, "reason": "leader_uuid_lookup_failed", "error": "boom"}),
698
880
  );
699
881
  same_repr(
700
- &validate_leader_uuid_from_targets(&json!({"provider": "codex"}), &json!({"ok": false})),
882
+ &validate_leader_uuid_from_targets(
883
+ &json!({"provider": "codex"}),
884
+ &json!({"ok": false}),
885
+ ),
701
886
  &json!({"ok": false, "reason": "leader_uuid_lookup_failed", "error": "tmux target scan failed"}),
702
887
  );
703
888
  same_repr(
@@ -79,23 +79,17 @@ mod tests {
79
79
 
80
80
  #[test]
81
81
  fn refuses_empty_team_key() {
82
- assert!(
83
- SessionAttributionKey::new(Provider::Claude, "", "agent_a", "sess_1").is_none()
84
- );
82
+ assert!(SessionAttributionKey::new(Provider::Claude, "", "agent_a", "sess_1").is_none());
85
83
  }
86
84
 
87
85
  #[test]
88
86
  fn refuses_empty_agent_id() {
89
- assert!(
90
- SessionAttributionKey::new(Provider::Claude, "alpha", "", "sess_1").is_none()
91
- );
87
+ assert!(SessionAttributionKey::new(Provider::Claude, "alpha", "", "sess_1").is_none());
92
88
  }
93
89
 
94
90
  #[test]
95
91
  fn refuses_empty_session_id() {
96
- assert!(
97
- SessionAttributionKey::new(Provider::Claude, "alpha", "agent_a", "").is_none()
98
- );
92
+ assert!(SessionAttributionKey::new(Provider::Claude, "alpha", "agent_a", "").is_none());
99
93
  }
100
94
 
101
95
  #[test]
@@ -119,7 +113,8 @@ mod tests {
119
113
  /// (provider, team_key, agent_id, session_id) tuple).
120
114
  #[test]
121
115
  fn different_team_keys_produce_different_keys() {
122
- let alpha = SessionAttributionKey::new(Provider::Claude, "alpha", "reviewer", "s1").unwrap();
116
+ let alpha =
117
+ SessionAttributionKey::new(Provider::Claude, "alpha", "reviewer", "s1").unwrap();
123
118
  let beta = SessionAttributionKey::new(Provider::Claude, "beta", "reviewer", "s1").unwrap();
124
119
  assert_ne!(alpha, beta);
125
120
  }
@@ -127,8 +122,10 @@ mod tests {
127
122
  /// Same team but different agent_id → distinct (per-worker isolation).
128
123
  #[test]
129
124
  fn different_agent_ids_produce_different_keys() {
130
- let reviewer = SessionAttributionKey::new(Provider::Claude, "alpha", "reviewer", "s1").unwrap();
131
- let frontend = SessionAttributionKey::new(Provider::Claude, "alpha", "frontend", "s1").unwrap();
125
+ let reviewer =
126
+ SessionAttributionKey::new(Provider::Claude, "alpha", "reviewer", "s1").unwrap();
127
+ let frontend =
128
+ SessionAttributionKey::new(Provider::Claude, "alpha", "frontend", "s1").unwrap();
132
129
  assert_ne!(reviewer, frontend);
133
130
  }
134
131
  }
@@ -49,11 +49,15 @@ pub enum StateError {
49
49
  Io(#[from] std::io::Error),
50
50
  #[error("json: {0}")]
51
51
  Json(#[from] serde_json::Error),
52
- #[error("{0} is locked by another team-agent process; serialize team-agent {0} calls and retry")]
52
+ #[error(
53
+ "{0} is locked by another team-agent process; serialize team-agent {0} calls and retry"
54
+ )]
53
55
  Locked(String),
54
56
  /// self-heal 也失败(原 state 仍可见);携带最终错误。
55
57
  #[error("state save failed after self-heal: {0}")]
56
58
  SaveFailed(String),
59
+ #[error("state save conflict: {0}")]
60
+ SaveConflict(String),
57
61
  /// `select_runtime_state` team 选择失败(`team_agent.errors.RuntimeError` 等价):
58
62
  /// 歧义 / 未找到。携带的 `String` == Python `str(exc)`(供 `resolve_team_scoped_state`
59
63
  /// 透传为 `team_target_unresolved` 的 `error` 字段)。