@team-agent/installer 0.4.11 → 0.5.1
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 +98 -5
- package/crates/team-agent/src/cli/mod.rs +2 -0
- package/crates/team-agent/src/cli/named_address.rs +864 -0
- package/crates/team-agent/src/cli/send.rs +104 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +4 -0
- 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/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 +169 -113
- 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 +6 -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 +35 -5
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -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/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
|
@@ -14,7 +14,12 @@ use std::path::Path;
|
|
|
14
14
|
use serde_json::{json, Map, Value};
|
|
15
15
|
|
|
16
16
|
use super::StateError;
|
|
17
|
-
use crate::state::persist::{
|
|
17
|
+
use crate::state::persist::{
|
|
18
|
+
load_runtime_state, save_runtime_state_with_deleted_agents,
|
|
19
|
+
save_runtime_state_with_lifecycle_topology_authority,
|
|
20
|
+
save_runtime_state_with_team_tombstone_lifecycle_topology_authority,
|
|
21
|
+
save_runtime_state_with_team_tombstoned_agents,
|
|
22
|
+
};
|
|
18
23
|
|
|
19
24
|
/// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
|
|
20
25
|
/// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
|
|
@@ -38,7 +43,9 @@ pub fn team_state_key(state: &Value) -> String {
|
|
|
38
43
|
} else {
|
|
39
44
|
path.parent().and_then(Path::file_name)
|
|
40
45
|
};
|
|
41
|
-
let key = key
|
|
46
|
+
let key = key
|
|
47
|
+
.map(|s| s.to_string_lossy().into_owned())
|
|
48
|
+
.unwrap_or_default();
|
|
42
49
|
if !key.is_empty() && key != ".team" && key != "runtime" {
|
|
43
50
|
return key;
|
|
44
51
|
}
|
|
@@ -53,9 +60,17 @@ pub fn team_state_key(state: &Value) -> String {
|
|
|
53
60
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
54
61
|
pub enum OwnerTeamResolution {
|
|
55
62
|
Canonical(String),
|
|
56
|
-
LegacyAlias {
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
LegacyAlias {
|
|
64
|
+
requested: String,
|
|
65
|
+
canonical: String,
|
|
66
|
+
},
|
|
67
|
+
Unresolved {
|
|
68
|
+
requested: String,
|
|
69
|
+
},
|
|
70
|
+
Ambiguous {
|
|
71
|
+
requested: String,
|
|
72
|
+
matches: Vec<String>,
|
|
73
|
+
},
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
impl OwnerTeamResolution {
|
|
@@ -71,14 +86,19 @@ impl OwnerTeamResolution {
|
|
|
71
86
|
pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamResolution {
|
|
72
87
|
let requested = owner_team_id.trim();
|
|
73
88
|
if requested.is_empty() {
|
|
74
|
-
return OwnerTeamResolution::Unresolved {
|
|
89
|
+
return OwnerTeamResolution::Unresolved {
|
|
90
|
+
requested: owner_team_id.to_string(),
|
|
91
|
+
};
|
|
75
92
|
}
|
|
76
93
|
let teams = state.get("teams").and_then(Value::as_object);
|
|
77
94
|
if teams.is_some_and(|teams| teams.contains_key(requested)) {
|
|
78
95
|
return OwnerTeamResolution::Canonical(requested.to_string());
|
|
79
96
|
}
|
|
80
97
|
if teams.is_none_or(Map::is_empty) {
|
|
81
|
-
let active = state
|
|
98
|
+
let active = state
|
|
99
|
+
.get("active_team_key")
|
|
100
|
+
.and_then(Value::as_str)
|
|
101
|
+
.unwrap_or("");
|
|
82
102
|
let derived = team_state_key(state);
|
|
83
103
|
if active == requested || derived == requested {
|
|
84
104
|
return OwnerTeamResolution::Canonical(requested.to_string());
|
|
@@ -98,7 +118,9 @@ pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamRes
|
|
|
98
118
|
return OwnerTeamResolution::Canonical(requested.to_string());
|
|
99
119
|
}
|
|
100
120
|
let Some(teams) = teams else {
|
|
101
|
-
return OwnerTeamResolution::Unresolved {
|
|
121
|
+
return OwnerTeamResolution::Unresolved {
|
|
122
|
+
requested: requested.to_string(),
|
|
123
|
+
};
|
|
102
124
|
};
|
|
103
125
|
let mut matches = Vec::new();
|
|
104
126
|
for (key, entry) in teams {
|
|
@@ -109,12 +131,17 @@ pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamRes
|
|
|
109
131
|
matches.sort();
|
|
110
132
|
matches.dedup();
|
|
111
133
|
match matches.len() {
|
|
112
|
-
0 => OwnerTeamResolution::Unresolved {
|
|
134
|
+
0 => OwnerTeamResolution::Unresolved {
|
|
135
|
+
requested: requested.to_string(),
|
|
136
|
+
},
|
|
113
137
|
1 => OwnerTeamResolution::LegacyAlias {
|
|
114
138
|
requested: requested.to_string(),
|
|
115
139
|
canonical: matches.remove(0),
|
|
116
140
|
},
|
|
117
|
-
_ => OwnerTeamResolution::Ambiguous {
|
|
141
|
+
_ => OwnerTeamResolution::Ambiguous {
|
|
142
|
+
requested: requested.to_string(),
|
|
143
|
+
matches,
|
|
144
|
+
},
|
|
118
145
|
}
|
|
119
146
|
}
|
|
120
147
|
|
|
@@ -131,7 +158,12 @@ fn legacy_owner_team_aliases(entry: &Value) -> impl Iterator<Item = String> + '_
|
|
|
131
158
|
"/legacy_team_name",
|
|
132
159
|
"/legacy_alias",
|
|
133
160
|
];
|
|
134
|
-
let list_paths = [
|
|
161
|
+
let list_paths = [
|
|
162
|
+
"/legacy_aliases",
|
|
163
|
+
"/legacy_team_aliases",
|
|
164
|
+
"/legacy_owner_team_ids",
|
|
165
|
+
"/aliases",
|
|
166
|
+
];
|
|
135
167
|
let scalars = scalar_paths
|
|
136
168
|
.into_iter()
|
|
137
169
|
.filter_map(|path| entry.pointer(path).and_then(Value::as_str));
|
|
@@ -196,7 +228,9 @@ pub fn merge_workspace_team_state(existing: &Value, launched: &Value) -> Value {
|
|
|
196
228
|
// 异 key → existing 为基,两个 team 都进 teams(existing 仅在缺时 seed)。
|
|
197
229
|
let mut merged = to_object(existing);
|
|
198
230
|
let mut teams = take_object(merged.get("teams"));
|
|
199
|
-
teams
|
|
231
|
+
teams
|
|
232
|
+
.entry(existing_key)
|
|
233
|
+
.or_insert_with(|| compact_team_state(existing));
|
|
200
234
|
teams.insert(launched_key, compact_team_state(launched));
|
|
201
235
|
merged.insert("teams".to_string(), Value::Object(teams));
|
|
202
236
|
Value::Object(merged)
|
|
@@ -250,7 +284,11 @@ pub fn format_team_candidates(team_states: &Map<String, Value>) -> String {
|
|
|
250
284
|
let agents = if agent_keys.is_empty() {
|
|
251
285
|
"-".to_string()
|
|
252
286
|
} else {
|
|
253
|
-
agent_keys
|
|
287
|
+
agent_keys
|
|
288
|
+
.iter()
|
|
289
|
+
.map(|s| s.as_str())
|
|
290
|
+
.collect::<Vec<_>>()
|
|
291
|
+
.join(",")
|
|
254
292
|
};
|
|
255
293
|
let session = st
|
|
256
294
|
.get("session_name")
|
|
@@ -294,23 +332,37 @@ fn valid_owner_pane_id(pane_id: &str) -> bool {
|
|
|
294
332
|
/// `_project_top_level_view`(`state.py:167`,C8):把 `teams[team_key]` 投影成扁平顶层视图。
|
|
295
333
|
pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
|
|
296
334
|
// entry = _team_entry_from_state(...) or {} —— 空 dict 亦走 {} 分支(同内容)。
|
|
297
|
-
let entry_obj: Map<String, Value> =
|
|
298
|
-
|
|
335
|
+
let entry_obj: Map<String, Value> = team_entry_from_state(state, team_key)
|
|
336
|
+
.and_then(Value::as_object)
|
|
337
|
+
.cloned()
|
|
338
|
+
.unwrap_or_default();
|
|
299
339
|
let mut p = entry_obj.clone(); // projection = deepcopy(entry)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
340
|
+
// setdefault session_name/team_dir ← entry.get(...)(缺则插,值可为 null)。
|
|
341
|
+
// **0.3.24 excision** (U1-A real-machine RED v2): the ff38ab9 root-state
|
|
342
|
+
// `or_else` fallback was reverted along with the wave-2 U1-A drift fallback,
|
|
343
|
+
// since this projection change had no other consumer. v0.3.25 will re-introduce
|
|
344
|
+
// this together with the writer-shape + rediscover-writer fix — see
|
|
345
|
+
// `.team/artifacts/u1-a-realmachine-v2-fix-or-excise.md`.
|
|
306
346
|
if !p.contains_key("session_name") {
|
|
307
|
-
p.insert(
|
|
347
|
+
p.insert(
|
|
348
|
+
"session_name".to_string(),
|
|
349
|
+
entry_obj
|
|
350
|
+
.get("session_name")
|
|
351
|
+
.cloned()
|
|
352
|
+
.unwrap_or(Value::Null),
|
|
353
|
+
);
|
|
308
354
|
}
|
|
309
355
|
if !p.contains_key("team_dir") {
|
|
310
|
-
p.insert(
|
|
356
|
+
p.insert(
|
|
357
|
+
"team_dir".to_string(),
|
|
358
|
+
entry_obj.get("team_dir").cloned().unwrap_or(Value::Null),
|
|
359
|
+
);
|
|
311
360
|
}
|
|
312
361
|
// d[k]=v:active_team_key 原位更新或末尾插。
|
|
313
|
-
p.insert(
|
|
362
|
+
p.insert(
|
|
363
|
+
"active_team_key".to_string(),
|
|
364
|
+
Value::String(team_key.to_string()),
|
|
365
|
+
);
|
|
314
366
|
// 保全全量 teams 供消费者看兄弟 team(`state.get("teams") or {}` 的 truthy 语义)。
|
|
315
367
|
p.insert("teams".to_string(), py_or_empty_map(state.get("teams")));
|
|
316
368
|
let has_team_entries = state
|
|
@@ -336,8 +388,12 @@ pub fn project_top_level_view(state: &Value, team_key: &str) -> Value {
|
|
|
336
388
|
p.insert("leader_receiver".to_string(), v.clone());
|
|
337
389
|
}
|
|
338
390
|
let _ = has_team_entries; // silence unused warning; kept for clarity.
|
|
339
|
-
|
|
340
|
-
if state
|
|
391
|
+
// coordinator:仅顶层有 key 时 setdefault(投影里没有才插)。
|
|
392
|
+
if state
|
|
393
|
+
.as_object()
|
|
394
|
+
.is_some_and(|o| o.contains_key("coordinator"))
|
|
395
|
+
&& !p.contains_key("coordinator")
|
|
396
|
+
{
|
|
341
397
|
p.insert("coordinator".to_string(), state["coordinator"].clone());
|
|
342
398
|
}
|
|
343
399
|
Value::Object(p)
|
|
@@ -354,12 +410,18 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
|
|
|
354
410
|
if let Some(team) = team {
|
|
355
411
|
// 无 alive 但 team 命中 active_team_key / 派生 key → 直接以全量 state 投影。
|
|
356
412
|
if alive.is_empty() {
|
|
357
|
-
let active = state
|
|
413
|
+
let active = state
|
|
414
|
+
.get("active_team_key")
|
|
415
|
+
.and_then(Value::as_str)
|
|
416
|
+
.unwrap_or("");
|
|
358
417
|
if team == active || team == team_state_key(&state) {
|
|
359
418
|
let mut projection = state.clone();
|
|
360
419
|
match projection.as_object_mut() {
|
|
361
420
|
Some(o) => {
|
|
362
|
-
o.insert(
|
|
421
|
+
o.insert(
|
|
422
|
+
"active_team_key".to_string(),
|
|
423
|
+
Value::String(team.to_string()),
|
|
424
|
+
);
|
|
363
425
|
}
|
|
364
426
|
None => projection = json!({ "active_team_key": team }),
|
|
365
427
|
}
|
|
@@ -389,7 +451,10 @@ pub fn select_runtime_state(workspace: &Path, team: Option<&str>) -> Result<Valu
|
|
|
389
451
|
)));
|
|
390
452
|
}
|
|
391
453
|
// 无 team 参数:active 命中 → 投影;唯一 alive → 投影;无 alive → 全量;多 alive → 歧义。
|
|
392
|
-
let active = state
|
|
454
|
+
let active = state
|
|
455
|
+
.get("active_team_key")
|
|
456
|
+
.and_then(Value::as_str)
|
|
457
|
+
.filter(|s| !s.is_empty());
|
|
393
458
|
if let Some(active) = active {
|
|
394
459
|
if alive.contains_key(active) {
|
|
395
460
|
return Ok(project_top_level_view(&state, active));
|
|
@@ -412,7 +477,10 @@ fn team_selector_matches(team: &str, key: &str, value: &Value) -> bool {
|
|
|
412
477
|
if team == key {
|
|
413
478
|
return true;
|
|
414
479
|
}
|
|
415
|
-
let session = value
|
|
480
|
+
let session = value
|
|
481
|
+
.get("session_name")
|
|
482
|
+
.and_then(Value::as_str)
|
|
483
|
+
.unwrap_or("");
|
|
416
484
|
if team == session {
|
|
417
485
|
return true;
|
|
418
486
|
}
|
|
@@ -434,7 +502,10 @@ fn team_selector_matches(team: &str, key: &str, value: &Value) -> bool {
|
|
|
434
502
|
/// `ambiguous_team_target_result`(`state.py:226`):无显式 team 且多候选 → 拒绝 dict;否则 None。
|
|
435
503
|
pub fn ambiguous_team_target_result(state: &Value) -> Option<Value> {
|
|
436
504
|
let alive = team_state_candidates(state);
|
|
437
|
-
let active = state
|
|
505
|
+
let active = state
|
|
506
|
+
.get("active_team_key")
|
|
507
|
+
.and_then(Value::as_str)
|
|
508
|
+
.filter(|s| !s.is_empty());
|
|
438
509
|
if let Some(active) = active {
|
|
439
510
|
if alive.contains_key(active) {
|
|
440
511
|
return None;
|
|
@@ -491,6 +562,26 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
|
|
|
491
562
|
save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
|
|
492
563
|
}
|
|
493
564
|
|
|
565
|
+
pub(crate) fn save_team_scoped_state_reapplying_after_conflict<F>(
|
|
566
|
+
workspace: &Path,
|
|
567
|
+
team_state: &Value,
|
|
568
|
+
reapply: F,
|
|
569
|
+
) -> Result<(), StateError>
|
|
570
|
+
where
|
|
571
|
+
F: FnOnce(&mut Value),
|
|
572
|
+
{
|
|
573
|
+
match save_team_scoped_state(workspace, team_state) {
|
|
574
|
+
Ok(()) => Ok(()),
|
|
575
|
+
Err(StateError::SaveConflict(_)) => {
|
|
576
|
+
let target_key = team_state_key(team_state);
|
|
577
|
+
let mut latest = select_runtime_state(workspace, Some(&target_key))?;
|
|
578
|
+
reapply(&mut latest);
|
|
579
|
+
save_team_scoped_state(workspace, &latest)
|
|
580
|
+
}
|
|
581
|
+
Err(error) => Err(error),
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
494
585
|
pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
495
586
|
workspace: &Path,
|
|
496
587
|
team_state: &Value,
|
|
@@ -499,12 +590,20 @@ pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
|
499
590
|
save_team_scoped_state_with_merge_exceptions(workspace, team_state, deleted_agent_ids, &[])
|
|
500
591
|
}
|
|
501
592
|
|
|
502
|
-
pub(crate) fn
|
|
593
|
+
pub(crate) fn save_team_scoped_state_with_tombstone_lifecycle_topology_authority(
|
|
503
594
|
workspace: &Path,
|
|
504
595
|
team_state: &Value,
|
|
505
|
-
|
|
596
|
+
agent_ids: &[&str],
|
|
597
|
+
) -> Result<(), StateError> {
|
|
598
|
+
save_team_scoped_state_with_merge_options(workspace, team_state, &[], agent_ids, agent_ids)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
pub(crate) fn save_team_scoped_state_with_lifecycle_topology_authority(
|
|
602
|
+
workspace: &Path,
|
|
603
|
+
team_state: &Value,
|
|
604
|
+
agent_ids: &[&str],
|
|
506
605
|
) -> Result<(), StateError> {
|
|
507
|
-
|
|
606
|
+
save_team_scoped_state_with_merge_options(workspace, team_state, &[], &[], agent_ids)
|
|
508
607
|
}
|
|
509
608
|
|
|
510
609
|
fn save_team_scoped_state_with_merge_exceptions(
|
|
@@ -512,6 +611,22 @@ fn save_team_scoped_state_with_merge_exceptions(
|
|
|
512
611
|
team_state: &Value,
|
|
513
612
|
deleted_agent_ids: &[&str],
|
|
514
613
|
tombstoned_agent_ids: &[&str],
|
|
614
|
+
) -> Result<(), StateError> {
|
|
615
|
+
save_team_scoped_state_with_merge_options(
|
|
616
|
+
workspace,
|
|
617
|
+
team_state,
|
|
618
|
+
deleted_agent_ids,
|
|
619
|
+
tombstoned_agent_ids,
|
|
620
|
+
&[],
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
fn save_team_scoped_state_with_merge_options(
|
|
625
|
+
workspace: &Path,
|
|
626
|
+
team_state: &Value,
|
|
627
|
+
deleted_agent_ids: &[&str],
|
|
628
|
+
tombstoned_agent_ids: &[&str],
|
|
629
|
+
topology_agent_ids: &[&str],
|
|
515
630
|
) -> Result<(), StateError> {
|
|
516
631
|
let target_key = team_state_key(team_state);
|
|
517
632
|
let existing = load_runtime_state(workspace)?;
|
|
@@ -532,7 +647,11 @@ fn save_team_scoped_state_with_merge_exceptions(
|
|
|
532
647
|
.and_then(Value::as_str)
|
|
533
648
|
.is_some_and(|t| t == s)
|
|
534
649
|
});
|
|
535
|
-
if existing_primary_key
|
|
650
|
+
if existing_primary_key
|
|
651
|
+
.as_deref()
|
|
652
|
+
.is_some_and(|k| k != target_key)
|
|
653
|
+
&& same_session
|
|
654
|
+
{
|
|
536
655
|
existing_primary_key = Some(target_key.clone());
|
|
537
656
|
}
|
|
538
657
|
let existing_teams = take_object(existing.get("teams"));
|
|
@@ -541,6 +660,21 @@ fn save_team_scoped_state_with_merge_exceptions(
|
|
|
541
660
|
// not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
|
|
542
661
|
if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
|
|
543
662
|
let merged = compact_team_state(team_state);
|
|
663
|
+
if !topology_agent_ids.is_empty() {
|
|
664
|
+
if !tombstoned_agent_ids.is_empty() {
|
|
665
|
+
return save_runtime_state_with_team_tombstone_lifecycle_topology_authority(
|
|
666
|
+
workspace,
|
|
667
|
+
&merged,
|
|
668
|
+
&target_key,
|
|
669
|
+
topology_agent_ids,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
return save_runtime_state_with_lifecycle_topology_authority(
|
|
673
|
+
workspace,
|
|
674
|
+
&merged,
|
|
675
|
+
topology_agent_ids,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
544
678
|
if tombstoned_agent_ids.is_empty() {
|
|
545
679
|
return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
|
|
546
680
|
}
|
|
@@ -566,12 +700,36 @@ fn save_team_scoped_state_with_merge_exceptions(
|
|
|
566
700
|
};
|
|
567
701
|
merged.insert("teams".to_string(), Value::Object(teams));
|
|
568
702
|
// if not merged.get("teams"): merged.pop("teams", None) —— teams 为空 dict(falsy)则剔除。
|
|
569
|
-
if merged
|
|
703
|
+
if merged
|
|
704
|
+
.get("teams")
|
|
705
|
+
.and_then(Value::as_object)
|
|
706
|
+
.is_some_and(Map::is_empty)
|
|
707
|
+
{
|
|
570
708
|
merged.remove("teams");
|
|
571
709
|
}
|
|
572
710
|
if tombstoned_agent_ids.is_empty() {
|
|
573
|
-
|
|
711
|
+
if !topology_agent_ids.is_empty() {
|
|
712
|
+
save_runtime_state_with_lifecycle_topology_authority(
|
|
713
|
+
workspace,
|
|
714
|
+
&Value::Object(merged),
|
|
715
|
+
topology_agent_ids,
|
|
716
|
+
)
|
|
717
|
+
} else {
|
|
718
|
+
save_runtime_state_with_deleted_agents(
|
|
719
|
+
workspace,
|
|
720
|
+
&Value::Object(merged),
|
|
721
|
+
deleted_agent_ids,
|
|
722
|
+
)
|
|
723
|
+
}
|
|
574
724
|
} else {
|
|
725
|
+
if !topology_agent_ids.is_empty() {
|
|
726
|
+
return save_runtime_state_with_team_tombstone_lifecycle_topology_authority(
|
|
727
|
+
workspace,
|
|
728
|
+
&Value::Object(merged),
|
|
729
|
+
&target_key,
|
|
730
|
+
topology_agent_ids,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
575
733
|
save_runtime_state_with_team_tombstoned_agents(
|
|
576
734
|
workspace,
|
|
577
735
|
&Value::Object(merged),
|
|
@@ -608,7 +766,11 @@ fn take_object(v: Option<&Value>) -> Map<String, Value> {
|
|
|
608
766
|
|
|
609
767
|
/// Python `repr()` of a `str`(与 `model::spec::py_repr_str` 同实现,本地副本免改工作模块)。
|
|
610
768
|
fn py_repr_str(s: &str) -> String {
|
|
611
|
-
let quote = if s.contains('\'') && !s.contains('"') {
|
|
769
|
+
let quote = if s.contains('\'') && !s.contains('"') {
|
|
770
|
+
'"'
|
|
771
|
+
} else {
|
|
772
|
+
'\''
|
|
773
|
+
};
|
|
612
774
|
let mut out = String::new();
|
|
613
775
|
out.push(quote);
|
|
614
776
|
for c in s.chars() {
|
|
@@ -638,7 +800,10 @@ mod tests {
|
|
|
638
800
|
|
|
639
801
|
/// 比较紧凑序列化(serde_json::to_string 无空格)—— 同时锁值 + 键插入序。
|
|
640
802
|
fn same_repr(a: &Value, b: &Value) {
|
|
641
|
-
assert_eq!(
|
|
803
|
+
assert_eq!(
|
|
804
|
+
serde_json::to_string(a).unwrap(),
|
|
805
|
+
serde_json::to_string(b).unwrap()
|
|
806
|
+
);
|
|
642
807
|
}
|
|
643
808
|
|
|
644
809
|
fn temp_ws_with_state(state: &Value) -> std::path::PathBuf {
|
|
@@ -652,9 +817,18 @@ mod tests {
|
|
|
652
817
|
|
|
653
818
|
#[test]
|
|
654
819
|
fn team_state_key_cases() {
|
|
655
|
-
assert_eq!(
|
|
656
|
-
|
|
657
|
-
|
|
820
|
+
assert_eq!(
|
|
821
|
+
team_state_key(&json!({"team_dir": "/ws/.team/myteam"})),
|
|
822
|
+
"myteam"
|
|
823
|
+
);
|
|
824
|
+
assert_eq!(
|
|
825
|
+
team_state_key(&json!({"team_dir": "/ws/.team/runtime", "session_name": "sess"})),
|
|
826
|
+
"sess"
|
|
827
|
+
);
|
|
828
|
+
assert_eq!(
|
|
829
|
+
team_state_key(&json!({"spec_path": "/ws/.team/alpha/TEAM.md"})),
|
|
830
|
+
"alpha"
|
|
831
|
+
);
|
|
658
832
|
assert_eq!(team_state_key(&json!({"session_name": "sname"})), "sname");
|
|
659
833
|
assert_eq!(team_state_key(&json!({})), "current");
|
|
660
834
|
assert_eq!(team_state_key(&json!({"session_name": ""})), "current");
|
|
@@ -691,11 +865,20 @@ mod tests {
|
|
|
691
865
|
format_team_candidates(&team_state_candidates(&s)),
|
|
692
866
|
"Candidates: ALIVE_CASE session=- agents=-; alive1 session=sa agents=-; nostatus session=sn agents=-"
|
|
693
867
|
);
|
|
694
|
-
assert_eq!(
|
|
868
|
+
assert_eq!(
|
|
869
|
+
format_team_candidates(&Map::new()),
|
|
870
|
+
"No team state was found."
|
|
871
|
+
);
|
|
695
872
|
let mut m = Map::new();
|
|
696
|
-
m.insert(
|
|
873
|
+
m.insert(
|
|
874
|
+
"k2".to_string(),
|
|
875
|
+
json!({"session_name": "s2", "agents": {"b": {}, "a": {}}}),
|
|
876
|
+
);
|
|
697
877
|
m.insert("k1".to_string(), json!({}));
|
|
698
|
-
assert_eq!(
|
|
878
|
+
assert_eq!(
|
|
879
|
+
format_team_candidates(&m),
|
|
880
|
+
"Candidates: k1 session=- agents=-; k2 session=s2 agents=a,b"
|
|
881
|
+
);
|
|
699
882
|
}
|
|
700
883
|
|
|
701
884
|
#[test]
|
|
@@ -717,7 +900,8 @@ mod tests {
|
|
|
717
900
|
|
|
718
901
|
#[test]
|
|
719
902
|
fn project_top_level_view_does_not_fall_to_toplevel_owner_when_teams_exist() {
|
|
720
|
-
let s =
|
|
903
|
+
let s =
|
|
904
|
+
json!({"teams": {"t2": {"session_name": "s2"}}, "team_owner": {"pane_id": "%top2"}});
|
|
721
905
|
let expected = json!({
|
|
722
906
|
"session_name": "s2", "team_dir": null, "active_team_key": "t2",
|
|
723
907
|
"teams": {"t2": {"session_name": "s2"}},
|
|
@@ -728,7 +912,10 @@ mod tests {
|
|
|
728
912
|
#[test]
|
|
729
913
|
fn merge_three_branches_golden() {
|
|
730
914
|
same_repr(
|
|
731
|
-
&merge_workspace_team_state(
|
|
915
|
+
&merge_workspace_team_state(
|
|
916
|
+
&json!({}),
|
|
917
|
+
&json!({"team_dir": "/w/.team/t1", "session_name": "s1", "agents": {}}),
|
|
918
|
+
),
|
|
732
919
|
&json!({"team_dir": "/w/.team/t1", "session_name": "s1", "agents": {},
|
|
733
920
|
"teams": {"t1": {"team_dir": "/w/.team/t1", "session_name": "s1", "agents": {}}}}),
|
|
734
921
|
);
|
|
@@ -790,7 +977,9 @@ mod tests {
|
|
|
790
977
|
let empty = select_runtime_state(&ws, Some("")).unwrap_err();
|
|
791
978
|
let none = select_runtime_state(&ws, None).unwrap_err();
|
|
792
979
|
assert_eq!(empty.to_string(), none.to_string());
|
|
793
|
-
assert!(empty
|
|
980
|
+
assert!(empty
|
|
981
|
+
.to_string()
|
|
982
|
+
.starts_with("multiple teams found in this workspace"));
|
|
794
983
|
}
|
|
795
984
|
|
|
796
985
|
#[test]
|
|
@@ -823,7 +1012,9 @@ mod tests {
|
|
|
823
1012
|
|
|
824
1013
|
#[test]
|
|
825
1014
|
fn select_runtime_state_single_alive_projects() {
|
|
826
|
-
let ws = temp_ws_with_state(
|
|
1015
|
+
let ws = temp_ws_with_state(
|
|
1016
|
+
&json!({"teams": {"only": {"status": "alive", "session_name": "so"}}}),
|
|
1017
|
+
);
|
|
827
1018
|
let r = select_runtime_state(&ws, None).unwrap();
|
|
828
1019
|
assert_eq!(r["active_team_key"], json!("only"));
|
|
829
1020
|
assert_eq!(r["session_name"], json!("so"));
|
|
@@ -46,8 +46,8 @@ pub fn resolve_active_team(
|
|
|
46
46
|
let state = select_runtime_state(&run, team).or_else(|_| load_runtime_state(&run))?;
|
|
47
47
|
(run, state)
|
|
48
48
|
} else {
|
|
49
|
-
let run =
|
|
50
|
-
.map_err(|e| StateError::TeamSelect(e.to_string()))?;
|
|
49
|
+
let run =
|
|
50
|
+
canonical_run_workspace(input).map_err(|e| StateError::TeamSelect(e.to_string()))?;
|
|
51
51
|
if !input.exists()
|
|
52
52
|
&& !runtime_state_path(&run).exists()
|
|
53
53
|
&& !run.join(".team").exists()
|
|
@@ -78,13 +78,19 @@ pub fn resolve_active_team(
|
|
|
78
78
|
let legacy_ws = if explicit_spec.exists() {
|
|
79
79
|
Some(input.to_path_buf())
|
|
80
80
|
} else {
|
|
81
|
-
spec_workspace_from_state(&state)
|
|
82
|
-
|
|
81
|
+
spec_workspace_from_state(&state).or_else(|| {
|
|
82
|
+
run_workspace
|
|
83
|
+
.join("team.spec.yaml")
|
|
84
|
+
.exists()
|
|
85
|
+
.then(|| run_workspace.clone())
|
|
86
|
+
})
|
|
83
87
|
};
|
|
84
88
|
let legacy_spec = legacy_ws.as_ref().map(|ws| ws.join("team.spec.yaml"));
|
|
85
89
|
(legacy_ws, legacy_spec)
|
|
86
90
|
};
|
|
87
|
-
if matches!(mode, SelectorMode::RequireSpec)
|
|
91
|
+
if matches!(mode, SelectorMode::RequireSpec)
|
|
92
|
+
&& !spec_path.as_ref().is_some_and(|path| path.exists())
|
|
93
|
+
{
|
|
88
94
|
// 期望路径报 canonical runtime spec(重建落点),非用户目录。
|
|
89
95
|
let expected = spec_path.as_ref().cloned().unwrap_or(runtime_spec);
|
|
90
96
|
// E5 Bug2 N38:spec=中间产物,运行期由 restart 以角色定义重建;首装走 quick-start;
|
|
@@ -313,7 +313,16 @@
|
|
|
313
313
|
// ── 2. spawn_first / spawn_into frame via tmux_spawn_argv; canned output parses pane id ────────
|
|
314
314
|
#[test]
|
|
315
315
|
fn spawn_first_frames_via_new_session_builder_and_parses_pane_id() {
|
|
316
|
-
let
|
|
316
|
+
let pane_inventory =
|
|
317
|
+
"%3\tteamsess\t0\tw1\t0\t/dev/ttys003\tnode\t1\t/work/dir\t1\t0\t123\n";
|
|
318
|
+
let (be, rec) = backend_with(
|
|
319
|
+
MockResp::Out(ok("")),
|
|
320
|
+
vec![
|
|
321
|
+
MockResp::Out(ok("")),
|
|
322
|
+
MockResp::Out(ok("%3")),
|
|
323
|
+
MockResp::Out(ok(pane_inventory)),
|
|
324
|
+
],
|
|
325
|
+
);
|
|
317
326
|
let s = SessionName::new("teamsess");
|
|
318
327
|
let w = WindowName::new("w1");
|
|
319
328
|
let env = BTreeMap::from([("TEAM_AGENT_ID".to_string(), "w1".to_string())]);
|
|
@@ -329,11 +338,21 @@
|
|
|
329
338
|
);
|
|
330
339
|
assert!(cmd.contains("provider-bin"), "the provider argv must be in the sh -lc command; got {cmd}");
|
|
331
340
|
assert_eq!(result.pane_id.as_str(), "%3", "SpawnResult.pane_id must parse from the tmux output");
|
|
341
|
+
assert_eq!(result.child_pid, Some(123));
|
|
332
342
|
}
|
|
333
343
|
|
|
334
344
|
#[test]
|
|
335
345
|
fn spawn_into_frames_via_new_window_builder() {
|
|
336
|
-
let
|
|
346
|
+
let pane_inventory =
|
|
347
|
+
"%4\tteamsess\t1\tw2\t0\t/dev/ttys004\tnode\t1\t/work/dir\t1\t0\t124\n";
|
|
348
|
+
let (be, rec) = backend_with(
|
|
349
|
+
MockResp::Out(ok("")),
|
|
350
|
+
vec![
|
|
351
|
+
MockResp::Out(ok("")),
|
|
352
|
+
MockResp::Out(ok("%4")),
|
|
353
|
+
MockResp::Out(ok(pane_inventory)),
|
|
354
|
+
],
|
|
355
|
+
);
|
|
337
356
|
let s = SessionName::new("teamsess");
|
|
338
357
|
let w = WindowName::new("w2");
|
|
339
358
|
let result = be
|
|
@@ -349,6 +368,36 @@
|
|
|
349
368
|
assert_eq!(result.pane_id.as_str(), "%4");
|
|
350
369
|
}
|
|
351
370
|
|
|
371
|
+
#[test]
|
|
372
|
+
fn spawn_with_command_refuses_display_message_pane_owned_by_other_window() {
|
|
373
|
+
let pane_inventory =
|
|
374
|
+
"%5\tteamsess\t1\tw2\t0\t/dev/ttys005\tnode\t1\t/work/dir\t1\t0\t125\n";
|
|
375
|
+
let (be, _rec) = backend_with(
|
|
376
|
+
MockResp::Out(ok("")),
|
|
377
|
+
vec![
|
|
378
|
+
MockResp::Out(ok("")),
|
|
379
|
+
MockResp::Out(ok("%5")),
|
|
380
|
+
MockResp::Out(ok(pane_inventory)),
|
|
381
|
+
],
|
|
382
|
+
);
|
|
383
|
+
let err = be
|
|
384
|
+
.spawn_into(
|
|
385
|
+
&SessionName::new("teamsess"),
|
|
386
|
+
&WindowName::new("w1"),
|
|
387
|
+
&svec(&["provider-bin"]),
|
|
388
|
+
Path::new("/work/dir"),
|
|
389
|
+
&BTreeMap::new(),
|
|
390
|
+
)
|
|
391
|
+
.expect_err("display-message fallback to w2 pane must fail closed");
|
|
392
|
+
let msg = err.to_string();
|
|
393
|
+
assert!(
|
|
394
|
+
msg.contains("requested=teamsess:w1")
|
|
395
|
+
&& msg.contains("observed_pane=%5")
|
|
396
|
+
&& msg.contains("observed=teamsess:w2"),
|
|
397
|
+
"error must include requested/observed ownership evidence, got {msg}"
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
352
401
|
#[test]
|
|
353
402
|
fn spawn_split_selects_even_horizontal_not_tiled() {
|
|
354
403
|
let (be, rec) = backend_with(
|
|
@@ -675,11 +675,48 @@ impl TmuxBackend {
|
|
|
675
675
|
),
|
|
676
676
|
});
|
|
677
677
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
678
|
+
let pane_id = PaneId::new(pane);
|
|
679
|
+
let targets = self.list_targets()?;
|
|
680
|
+
if let Some(target) = targets.iter().find(|target| {
|
|
681
|
+
target.pane_id == pane_id
|
|
682
|
+
&& target.session.as_str() == session.as_str()
|
|
683
|
+
&& target
|
|
684
|
+
.window_name
|
|
685
|
+
.as_ref()
|
|
686
|
+
.is_some_and(|name| name.as_str() == window.as_str())
|
|
687
|
+
}) {
|
|
688
|
+
return Ok(SpawnResult {
|
|
689
|
+
pane_id,
|
|
690
|
+
session: session.clone(),
|
|
691
|
+
window: window.clone(),
|
|
692
|
+
child_pid: target.pane_pid,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
let observed = targets
|
|
696
|
+
.iter()
|
|
697
|
+
.find(|target| target.pane_id == pane_id)
|
|
698
|
+
.map(|target| {
|
|
699
|
+
format!(
|
|
700
|
+
"{}:{}",
|
|
701
|
+
target.session.as_str(),
|
|
702
|
+
target
|
|
703
|
+
.window_name
|
|
704
|
+
.as_ref()
|
|
705
|
+
.map(WindowName::as_str)
|
|
706
|
+
.unwrap_or("<unknown>")
|
|
707
|
+
)
|
|
708
|
+
})
|
|
709
|
+
.unwrap_or_else(|| "<missing-from-list-targets>".to_string());
|
|
710
|
+
Err(TransportError::Subprocess {
|
|
711
|
+
argv: pane_argv,
|
|
712
|
+
code: output.code,
|
|
713
|
+
stderr: format!(
|
|
714
|
+
"tmux spawn pane ownership mismatch: requested={}:{} observed_pane={} observed={}",
|
|
715
|
+
session.as_str(),
|
|
716
|
+
window.as_str(),
|
|
717
|
+
pane_id.as_str(),
|
|
718
|
+
observed
|
|
719
|
+
),
|
|
683
720
|
})
|
|
684
721
|
}
|
|
685
722
|
|