@team-agent/installer 0.4.10 → 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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +2 -15
- package/crates/team-agent/src/cli/emit.rs +62 -4
- package/crates/team-agent/src/compiler.rs +11 -20
- package/crates/team-agent/src/coordinator/orphan.rs +2 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
- package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
- package/crates/team-agent/src/coordinator/tick.rs +31 -932
- package/crates/team-agent/src/diagnose/orphans.rs +66 -8
- package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
- package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
- package/crates/team-agent/src/leader/rediscover.rs +1 -11
- package/crates/team-agent/src/leader/start.rs +169 -25
- package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
- package/crates/team-agent/src/lifecycle/launch.rs +135 -58
- package/crates/team-agent/src/lifecycle/lock.rs +302 -0
- package/crates/team-agent/src/lifecycle/mod.rs +1 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
- package/crates/team-agent/src/lifecycle/restart/agent.rs +436 -121
- package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
- package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +193 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +54 -15
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +648 -0
- package/crates/team-agent/src/lifecycle/tests.rs +3 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +24 -34
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
- package/crates/team-agent/src/mcp_server/tools.rs +157 -68
- package/crates/team-agent/src/messaging/activity.rs +43 -18
- package/crates/team-agent/src/messaging/delivery.rs +161 -97
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
- package/crates/team-agent/src/messaging/results.rs +34 -9
- package/crates/team-agent/src/messaging/scheduler.rs +52 -9
- package/crates/team-agent/src/model/spec.rs +10 -6
- package/crates/team-agent/src/provider/adapter.rs +10 -1038
- package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
- package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
- package/crates/team-agent/src/provider/classify.rs +23 -48
- package/crates/team-agent/src/provider/command.rs +16 -7
- package/crates/team-agent/src/provider/faults.rs +93 -53
- package/crates/team-agent/src/provider/session/capture.rs +4 -15
- package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
- package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
- package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
- package/crates/team-agent/src/provider/session_scan.rs +65 -27
- package/crates/team-agent/src/provider/tests/faults.rs +80 -0
- package/crates/team-agent/src/provider/types.rs +33 -1
- package/crates/team-agent/src/provider/wire.rs +171 -1
- package/crates/team-agent/src/state/identity.rs +242 -57
- package/crates/team-agent/src/state/identity_keys.rs +9 -12
- package/crates/team-agent/src/state/mod.rs +5 -1
- package/crates/team-agent/src/state/owner_gate.rs +59 -15
- package/crates/team-agent/src/state/ownership.rs +12 -11
- package/crates/team-agent/src/state/paths.rs +13 -6
- package/crates/team-agent/src/state/persist.rs +671 -128
- package/crates/team-agent/src/state/projection.rs +240 -49
- package/crates/team-agent/src/state/selector.rs +11 -5
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
- package/crates/team-agent/src/tmux_backend.rs +42 -5
- package/crates/team-agent/src/transport/test_support.rs +55 -6
- 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(
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
95
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
335
|
-
|
|
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
|
-
(
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
(
|
|
371
|
-
|
|
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 =>
|
|
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)
|
|
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('"') {
|
|
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(
|
|
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!(
|
|
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!(
|
|
492
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(&[
|
|
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 =
|
|
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"]
|
|
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")
|
|
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!(
|
|
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!(
|
|
561
|
-
|
|
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")
|
|
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!(
|
|
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")
|
|
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 =
|
|
624
|
-
|
|
625
|
-
|
|
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 =
|
|
652
|
-
|
|
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!(
|
|
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(
|
|
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!(
|
|
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
|
-
(
|
|
684
|
-
|
|
685
|
-
|
|
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": "
|
|
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(
|
|
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 =
|
|
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 =
|
|
131
|
-
|
|
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(
|
|
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` 字段)。
|