@team-agent/installer 0.3.0 → 0.3.2
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 +38 -7
- package/crates/team-agent/src/cli/emit.rs +182 -54
- package/crates/team-agent/src/cli/mod.rs +703 -35
- package/crates/team-agent/src/cli/status_port.rs +170 -44
- package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +1 -0
- package/crates/team-agent/src/coordinator/health.rs +130 -0
- package/crates/team-agent/src/leader/lease.rs +23 -2
- package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
- package/crates/team-agent/src/leader/tests/idle.rs +1 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
- package/crates/team-agent/src/leader/types.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch.rs +554 -65
- package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
- package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
- package/crates/team-agent/src/lifecycle/restart.rs +20 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
- package/crates/team-agent/src/lifecycle/types.rs +25 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
- package/crates/team-agent/src/mcp_server/wire.rs +81 -1
- package/crates/team-agent/src/messaging/delivery.rs +574 -12
- package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/results.rs +218 -49
- package/crates/team-agent/src/messaging/send.rs +15 -19
- package/crates/team-agent/src/provider/adapter.rs +95 -10
- package/crates/team-agent/src/provider/helpers.rs +10 -1
- package/crates/team-agent/src/state/identity.rs +3 -0
- package/crates/team-agent/src/state/persist.rs +113 -1
- package/crates/team-agent/src/state/projection.rs +127 -3
- package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
- package/crates/team-agent/src/tmux_backend.rs +124 -12
- package/npm/install.mjs +29 -7
- package/package.json +4 -4
|
@@ -277,15 +277,19 @@ impl ProviderAdapter for BasicProviderAdapter {
|
|
|
277
277
|
spawn_cwd: &Path,
|
|
278
278
|
_timeout_s: u64,
|
|
279
279
|
) -> Result<Option<CapturedSession>, ProviderError> {
|
|
280
|
-
let candidates = candidate_session_files(spawn_cwd, agent_id)?;
|
|
281
|
-
for
|
|
280
|
+
let candidates = candidate_session_files(self.provider, spawn_cwd, agent_id)?;
|
|
281
|
+
for candidate in candidates {
|
|
282
|
+
let path = candidate.path;
|
|
282
283
|
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
283
284
|
continue;
|
|
284
285
|
};
|
|
285
|
-
let records =
|
|
286
|
+
let records = parse_session_records(&text);
|
|
286
287
|
if records.is_empty() {
|
|
287
288
|
continue;
|
|
288
289
|
}
|
|
290
|
+
if candidate.requires_cwd_match && !provider_home_records_match_spawn_cwd(&records, spawn_cwd) {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
289
293
|
let session_id = records.iter().find_map(find_session_id);
|
|
290
294
|
if matches!(self.provider, Provider::Claude | Provider::ClaudeCode)
|
|
291
295
|
&& session_id.is_some()
|
|
@@ -518,18 +522,56 @@ fn command_on_path(name: &str) -> bool {
|
|
|
518
522
|
std::env::split_paths(&path).any(|dir| dir.join(name).is_file())
|
|
519
523
|
}
|
|
520
524
|
|
|
521
|
-
|
|
525
|
+
struct SessionCandidate {
|
|
526
|
+
path: PathBuf,
|
|
527
|
+
requires_cwd_match: bool,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
fn candidate_session_files(
|
|
531
|
+
provider: Provider,
|
|
532
|
+
spawn_cwd: &Path,
|
|
533
|
+
agent_id: &str,
|
|
534
|
+
) -> Result<Vec<SessionCandidate>, ProviderError> {
|
|
522
535
|
let mut out = Vec::new();
|
|
523
|
-
collect_candidate_files(spawn_cwd, agent_id, 0, &mut out)?;
|
|
524
|
-
|
|
536
|
+
collect_candidate_files(spawn_cwd, agent_id, 0, false, &mut out)?;
|
|
537
|
+
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
|
538
|
+
match provider {
|
|
539
|
+
Provider::Codex => {
|
|
540
|
+
collect_optional_candidate_files(&home.join(".codex").join("sessions"), agent_id, &mut out)?;
|
|
541
|
+
}
|
|
542
|
+
Provider::Claude | Provider::ClaudeCode => {
|
|
543
|
+
collect_optional_candidate_files(&home.join(".claude").join("sessions"), agent_id, &mut out)?;
|
|
544
|
+
collect_optional_candidate_files(&home.join(".claude").join("projects"), agent_id, &mut out)?;
|
|
545
|
+
}
|
|
546
|
+
Provider::GeminiCli | Provider::Fake => {}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
out.sort_by(|a, b| {
|
|
550
|
+
a.requires_cwd_match
|
|
551
|
+
.cmp(&b.requires_cwd_match)
|
|
552
|
+
.then_with(|| a.path.to_string_lossy().cmp(&b.path.to_string_lossy()))
|
|
553
|
+
});
|
|
554
|
+
out.dedup_by(|a, b| a.path == b.path && a.requires_cwd_match == b.requires_cwd_match);
|
|
525
555
|
Ok(out)
|
|
526
556
|
}
|
|
527
557
|
|
|
558
|
+
fn collect_optional_candidate_files(
|
|
559
|
+
dir: &Path,
|
|
560
|
+
agent_id: &str,
|
|
561
|
+
out: &mut Vec<SessionCandidate>,
|
|
562
|
+
) -> Result<(), ProviderError> {
|
|
563
|
+
if dir.exists() {
|
|
564
|
+
let _ = collect_candidate_files(dir, agent_id, 0, true, out);
|
|
565
|
+
}
|
|
566
|
+
Ok(())
|
|
567
|
+
}
|
|
568
|
+
|
|
528
569
|
fn collect_candidate_files(
|
|
529
570
|
dir: &Path,
|
|
530
571
|
agent_id: &str,
|
|
531
572
|
depth: usize,
|
|
532
|
-
|
|
573
|
+
requires_cwd_match: bool,
|
|
574
|
+
out: &mut Vec<SessionCandidate>,
|
|
533
575
|
) -> Result<(), ProviderError> {
|
|
534
576
|
if depth > 4 {
|
|
535
577
|
return Ok(());
|
|
@@ -545,9 +587,12 @@ fn collect_candidate_files(
|
|
|
545
587
|
};
|
|
546
588
|
let path = entry.path();
|
|
547
589
|
if path.is_dir() {
|
|
548
|
-
collect_candidate_files(&path, agent_id, depth.saturating_add(1), out)?;
|
|
590
|
+
collect_candidate_files(&path, agent_id, depth.saturating_add(1), requires_cwd_match, out)?;
|
|
549
591
|
} else if looks_like_session_file(&path, agent_id) {
|
|
550
|
-
out.push(
|
|
592
|
+
out.push(SessionCandidate {
|
|
593
|
+
path,
|
|
594
|
+
requires_cwd_match,
|
|
595
|
+
});
|
|
551
596
|
}
|
|
552
597
|
}
|
|
553
598
|
Ok(())
|
|
@@ -572,6 +617,46 @@ fn looks_like_session_file(path: &Path, agent_id: &str) -> bool {
|
|
|
572
617
|
|| (!agent_id.is_empty() && name.contains(agent_id))
|
|
573
618
|
}
|
|
574
619
|
|
|
620
|
+
fn parse_session_records(text: &str) -> Vec<serde_json::Value> {
|
|
621
|
+
match serde_json::from_str::<serde_json::Value>(text) {
|
|
622
|
+
Ok(serde_json::Value::Array(items)) => items,
|
|
623
|
+
Ok(value) => vec![value],
|
|
624
|
+
Err(_) => parse_jsonl_records(text),
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
fn provider_home_records_match_spawn_cwd(records: &[serde_json::Value], spawn_cwd: &Path) -> bool {
|
|
629
|
+
let cwd_values: Vec<String> = records.iter().filter_map(record_cwd).collect();
|
|
630
|
+
!cwd_values.is_empty()
|
|
631
|
+
&& cwd_values
|
|
632
|
+
.iter()
|
|
633
|
+
.any(|cwd| paths_equivalent(Path::new(cwd), spawn_cwd))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
fn record_cwd(record: &serde_json::Value) -> Option<String> {
|
|
637
|
+
record
|
|
638
|
+
.get("cwd")
|
|
639
|
+
.and_then(serde_json::Value::as_str)
|
|
640
|
+
.or_else(|| {
|
|
641
|
+
record
|
|
642
|
+
.get("session_meta")
|
|
643
|
+
.and_then(|v| v.get("payload"))
|
|
644
|
+
.or_else(|| record.get("payload"))
|
|
645
|
+
.and_then(|v| v.get("cwd"))
|
|
646
|
+
.and_then(serde_json::Value::as_str)
|
|
647
|
+
})
|
|
648
|
+
.map(ToString::to_string)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
fn paths_equivalent(left: &Path, right: &Path) -> bool {
|
|
652
|
+
if left == right {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
|
|
656
|
+
let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
|
|
657
|
+
left == right || left.starts_with(&right)
|
|
658
|
+
}
|
|
659
|
+
|
|
575
660
|
/// `true` iff any path component is `.team` (the Team Agent runtime/logs root) — used
|
|
576
661
|
/// to gate session-file detection so `<workspace>/.team/logs/events.jsonl`,
|
|
577
662
|
/// `.team/runtime/team.db`, etc. are NEVER mistaken for a provider transcript.
|
|
@@ -790,7 +875,7 @@ fn current_team_agent_command() -> String {
|
|
|
790
875
|
}
|
|
791
876
|
|
|
792
877
|
fn has_cwd_field(record: &serde_json::Value) -> bool {
|
|
793
|
-
record
|
|
878
|
+
record_cwd(record).is_some()
|
|
794
879
|
}
|
|
795
880
|
|
|
796
881
|
fn next_session_token() -> String {
|
|
@@ -22,9 +22,18 @@ pub(crate) fn find_session_id(record: &serde_json::Value) -> Option<String> {
|
|
|
22
22
|
if let Some(s) = record.get("sessionId").and_then(serde_json::Value::as_str) {
|
|
23
23
|
return Some(s.to_string());
|
|
24
24
|
}
|
|
25
|
-
record
|
|
25
|
+
if let Some(s) = record
|
|
26
26
|
.get("session_id")
|
|
27
27
|
.and_then(serde_json::Value::as_str)
|
|
28
|
+
{
|
|
29
|
+
return Some(s.to_string());
|
|
30
|
+
}
|
|
31
|
+
record
|
|
32
|
+
.get("session_meta")
|
|
33
|
+
.and_then(|v| v.get("payload"))
|
|
34
|
+
.or_else(|| record.get("payload"))
|
|
35
|
+
.and_then(|v| v.get("id"))
|
|
36
|
+
.and_then(serde_json::Value::as_str)
|
|
28
37
|
.map(ToString::to_string)
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -332,6 +332,9 @@ pub fn apply_first_time_leader_binding(
|
|
|
332
332
|
r.insert("leader_session_uuid".to_string(), id_uuid.clone());
|
|
333
333
|
r.insert("machine_fingerprint".to_string(), id_fp.clone());
|
|
334
334
|
r.insert("owner_epoch".to_string(), json!(0));
|
|
335
|
+
if let Some(socket) = crate::tmux_backend::socket_name_from_tmux_env() {
|
|
336
|
+
r.insert("tmux_socket".to_string(), json!(socket));
|
|
337
|
+
}
|
|
335
338
|
}
|
|
336
339
|
let owner = json!({
|
|
337
340
|
"pane_id": receiver.get("pane_id").cloned().unwrap_or(Value::Null),
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
//! (`identity::migrate_state_identity`,补 leader_session_uuid)→ `_migrate_active_team_key`
|
|
21
21
|
//! (seed active 指针);任一改动 → `save_runtime_state` 回写。不存在且命中缓存 → 返回缓存 deepcopy。
|
|
22
22
|
|
|
23
|
-
use std::collections::HashMap;
|
|
23
|
+
use std::collections::{BTreeSet, HashMap};
|
|
24
24
|
use std::io;
|
|
25
25
|
use std::path::{Path, PathBuf};
|
|
26
26
|
use std::sync::{LazyLock, Mutex};
|
|
@@ -170,6 +170,14 @@ impl Drop for RuntimeLock {
|
|
|
170
170
|
/// `save_runtime_state`(bug-084)。`state` 是 state.json 的内存 Value(插入序保留)。
|
|
171
171
|
/// 注:Python 在此还调 `_migrate_state_identity`(identity slice 落地后接入;本 slice 不改 state 内容)。
|
|
172
172
|
pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateError> {
|
|
173
|
+
save_runtime_state_with_deleted_agents(workspace, state, &[])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
pub(crate) fn save_runtime_state_with_deleted_agents(
|
|
177
|
+
workspace: &Path,
|
|
178
|
+
state: &Value,
|
|
179
|
+
deleted_agent_ids: &[&str],
|
|
180
|
+
) -> Result<(), StateError> {
|
|
173
181
|
let path = runtime_state_path(workspace);
|
|
174
182
|
if cache_equals(&path, state) {
|
|
175
183
|
return Ok(());
|
|
@@ -203,6 +211,15 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
|
|
|
203
211
|
if let Some(parent) = path.parent() {
|
|
204
212
|
std::fs::create_dir_all(parent)?;
|
|
205
213
|
}
|
|
214
|
+
if let Some(latest) = read_latest_state_under_lock(workspace, &path) {
|
|
215
|
+
let deleted = deleted_agent_ids
|
|
216
|
+
.iter()
|
|
217
|
+
.copied()
|
|
218
|
+
.filter(|id| !id.is_empty())
|
|
219
|
+
.map(str::to_string)
|
|
220
|
+
.collect::<BTreeSet<_>>();
|
|
221
|
+
preserve_latest_roster_entries(&mut migrated, &latest, &deleted);
|
|
222
|
+
}
|
|
206
223
|
// 字节对拍 Python json.dumps(indent=2, ensure_ascii=False)(无尾换行)。
|
|
207
224
|
let payload = serde_json::to_string_pretty(&migrated)?;
|
|
208
225
|
let delays = [0.05_f64, 0.2, 0.5];
|
|
@@ -243,6 +260,101 @@ pub fn save_runtime_state(workspace: &Path, state: &Value) -> Result<(), StateEr
|
|
|
243
260
|
Err(StateError::SaveFailed("retry loop exhausted without return".to_string()))
|
|
244
261
|
}
|
|
245
262
|
|
|
263
|
+
fn read_latest_state_under_lock(workspace: &Path, path: &Path) -> Option<Value> {
|
|
264
|
+
let text = std::fs::read_to_string(path).ok()?;
|
|
265
|
+
let mut latest = serde_json::from_str::<Value>(&text).ok()?;
|
|
266
|
+
normalize_agent_session_state(&mut latest);
|
|
267
|
+
let _ = migrate_state_identity(&mut latest, &SystemEnv, workspace);
|
|
268
|
+
let _ = migrate_active_team_key(&mut latest);
|
|
269
|
+
Some(latest)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fn preserve_latest_roster_entries(incoming: &mut Value, latest: &Value, deleted_agent_ids: &BTreeSet<String>) {
|
|
273
|
+
if !same_runtime_projection(incoming, latest) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
preserve_missing_agents(incoming.get_mut("agents"), latest.get("agents"), deleted_agent_ids);
|
|
277
|
+
|
|
278
|
+
let active_team = active_team_key(incoming).or_else(|| active_team_key(latest));
|
|
279
|
+
if let Some(active_team) = active_team.as_deref() {
|
|
280
|
+
let latest_active_agents = latest
|
|
281
|
+
.get("teams")
|
|
282
|
+
.and_then(Value::as_object)
|
|
283
|
+
.and_then(|teams| teams.get(active_team))
|
|
284
|
+
.and_then(|entry| entry.get("agents"));
|
|
285
|
+
preserve_missing_agents(incoming.get_mut("agents"), latest_active_agents, deleted_agent_ids);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let latest_teams = latest.get("teams").and_then(Value::as_object);
|
|
289
|
+
let Some(incoming_teams) = incoming.get_mut("teams").and_then(Value::as_object_mut) else {
|
|
290
|
+
return;
|
|
291
|
+
};
|
|
292
|
+
if let Some(latest_teams) = latest_teams {
|
|
293
|
+
for (team, latest_entry) in latest_teams {
|
|
294
|
+
let Some(incoming_entry) = incoming_teams.get_mut(team) else {
|
|
295
|
+
continue;
|
|
296
|
+
};
|
|
297
|
+
preserve_missing_agents(
|
|
298
|
+
incoming_entry.get_mut("agents"),
|
|
299
|
+
latest_entry.get("agents"),
|
|
300
|
+
deleted_agent_ids,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if let Some(active_team) = active_team.as_deref() {
|
|
305
|
+
let latest_top_agents = latest.get("agents");
|
|
306
|
+
if let Some(incoming_entry) = incoming_teams.get_mut(active_team) {
|
|
307
|
+
preserve_missing_agents(incoming_entry.get_mut("agents"), latest_top_agents, deleted_agent_ids);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fn preserve_missing_agents(
|
|
313
|
+
incoming_agents: Option<&mut Value>,
|
|
314
|
+
latest_agents: Option<&Value>,
|
|
315
|
+
deleted_agent_ids: &BTreeSet<String>,
|
|
316
|
+
) {
|
|
317
|
+
let Some(incoming_agents) = incoming_agents else {
|
|
318
|
+
return;
|
|
319
|
+
};
|
|
320
|
+
let Some(incoming_map) = incoming_agents.as_object_mut() else {
|
|
321
|
+
return;
|
|
322
|
+
};
|
|
323
|
+
let Some(latest_map) = latest_agents.and_then(Value::as_object) else {
|
|
324
|
+
return;
|
|
325
|
+
};
|
|
326
|
+
for (agent_id, latest_agent) in latest_map {
|
|
327
|
+
if deleted_agent_ids.contains(agent_id) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
incoming_map
|
|
331
|
+
.entry(agent_id.clone())
|
|
332
|
+
.or_insert_with(|| latest_agent.clone());
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
fn same_runtime_projection(left: &Value, right: &Value) -> bool {
|
|
337
|
+
let left_session = left.get("session_name").and_then(Value::as_str);
|
|
338
|
+
let right_session = right.get("session_name").and_then(Value::as_str);
|
|
339
|
+
if left_session.is_some() && right_session.is_some() && left_session != right_session {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
let left_team = active_team_key(left);
|
|
343
|
+
let right_team = active_team_key(right);
|
|
344
|
+
if left_team.is_some() && right_team.is_some() && left_team != right_team {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
true
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
fn active_team_key(state: &Value) -> Option<String> {
|
|
351
|
+
state
|
|
352
|
+
.get("active_team_key")
|
|
353
|
+
.and_then(Value::as_str)
|
|
354
|
+
.filter(|team| !team.is_empty() && *team != "current")
|
|
355
|
+
.map(str::to_string)
|
|
356
|
+
}
|
|
357
|
+
|
|
246
358
|
/// `_self_heal_runtime_state`:重建 inode(heal-tmp + backup-rename),绝不 in-place truncate。
|
|
247
359
|
fn self_heal(
|
|
248
360
|
workspace: &Path,
|
|
@@ -14,7 +14,7 @@ use std::path::Path;
|
|
|
14
14
|
use serde_json::{json, Map, Value};
|
|
15
15
|
|
|
16
16
|
use super::StateError;
|
|
17
|
-
use crate::state::persist::{load_runtime_state,
|
|
17
|
+
use crate::state::persist::{load_runtime_state, save_runtime_state_with_deleted_agents};
|
|
18
18
|
|
|
19
19
|
/// `team_state_key`(`state.py:93`):从 team_dir(.name)/spec_path(.parent.name)派生 team key,
|
|
20
20
|
/// 跳过 `.team`/`runtime`;兜底 `session_name` 或 `"current"`。
|
|
@@ -43,6 +43,122 @@ pub fn team_state_key(state: &Value) -> String {
|
|
|
43
43
|
.map_or_else(|| "current".to_string(), str::to_string)
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
47
|
+
pub enum OwnerTeamResolution {
|
|
48
|
+
Canonical(String),
|
|
49
|
+
LegacyAlias { requested: String, canonical: String },
|
|
50
|
+
Unresolved { requested: String },
|
|
51
|
+
Ambiguous { requested: String, matches: Vec<String> },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
impl OwnerTeamResolution {
|
|
55
|
+
pub fn canonical_key(&self) -> Option<&str> {
|
|
56
|
+
match self {
|
|
57
|
+
OwnerTeamResolution::Canonical(key)
|
|
58
|
+
| OwnerTeamResolution::LegacyAlias { canonical: key, .. } => Some(key),
|
|
59
|
+
OwnerTeamResolution::Unresolved { .. } | OwnerTeamResolution::Ambiguous { .. } => None,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub fn resolve_owner_team_id(state: &Value, owner_team_id: &str) -> OwnerTeamResolution {
|
|
65
|
+
let requested = owner_team_id.trim();
|
|
66
|
+
if requested.is_empty() {
|
|
67
|
+
return OwnerTeamResolution::Unresolved { requested: owner_team_id.to_string() };
|
|
68
|
+
}
|
|
69
|
+
let teams = state.get("teams").and_then(Value::as_object);
|
|
70
|
+
if teams.is_some_and(|teams| teams.contains_key(requested)) {
|
|
71
|
+
if has_top_level_runtime_content(state) {
|
|
72
|
+
return OwnerTeamResolution::Canonical(requested.to_string());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if teams.is_none_or(Map::is_empty) {
|
|
76
|
+
let active = state.get("active_team_key").and_then(Value::as_str).unwrap_or("");
|
|
77
|
+
let derived = team_state_key(state);
|
|
78
|
+
if active == requested || derived == requested {
|
|
79
|
+
return OwnerTeamResolution::Canonical(requested.to_string());
|
|
80
|
+
}
|
|
81
|
+
if !active.is_empty() {
|
|
82
|
+
return OwnerTeamResolution::LegacyAlias {
|
|
83
|
+
requested: requested.to_string(),
|
|
84
|
+
canonical: active.to_string(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if derived != "current" {
|
|
88
|
+
return OwnerTeamResolution::LegacyAlias {
|
|
89
|
+
requested: requested.to_string(),
|
|
90
|
+
canonical: derived,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return OwnerTeamResolution::Canonical(requested.to_string());
|
|
94
|
+
}
|
|
95
|
+
let Some(teams) = teams else {
|
|
96
|
+
return OwnerTeamResolution::Unresolved { requested: requested.to_string() };
|
|
97
|
+
};
|
|
98
|
+
let mut matches = Vec::new();
|
|
99
|
+
for (key, entry) in teams {
|
|
100
|
+
if legacy_owner_team_aliases(entry).any(|alias| alias == requested) {
|
|
101
|
+
matches.push(key.clone());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
matches.sort();
|
|
105
|
+
matches.dedup();
|
|
106
|
+
match matches.len() {
|
|
107
|
+
0 => OwnerTeamResolution::Unresolved { requested: requested.to_string() },
|
|
108
|
+
1 => OwnerTeamResolution::LegacyAlias {
|
|
109
|
+
requested: requested.to_string(),
|
|
110
|
+
canonical: matches.remove(0),
|
|
111
|
+
},
|
|
112
|
+
_ => OwnerTeamResolution::Ambiguous { requested: requested.to_string(), matches },
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn has_top_level_runtime_content(state: &Value) -> bool {
|
|
117
|
+
[
|
|
118
|
+
"session_name",
|
|
119
|
+
"team_dir",
|
|
120
|
+
"spec_path",
|
|
121
|
+
"workspace",
|
|
122
|
+
"agents",
|
|
123
|
+
"tasks",
|
|
124
|
+
"leader_receiver",
|
|
125
|
+
"team_owner",
|
|
126
|
+
]
|
|
127
|
+
.into_iter()
|
|
128
|
+
.any(|key| state.get(key).is_some_and(super::json_truthy))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fn legacy_owner_team_aliases(entry: &Value) -> impl Iterator<Item = String> + '_ {
|
|
132
|
+
let scalar_paths = [
|
|
133
|
+
"/team/name",
|
|
134
|
+
"/team/id",
|
|
135
|
+
"/name",
|
|
136
|
+
"/team_name",
|
|
137
|
+
"/team_id",
|
|
138
|
+
"/spec_name",
|
|
139
|
+
"/legacy_owner_team_id",
|
|
140
|
+
"/legacy_team_id",
|
|
141
|
+
"/legacy_team_name",
|
|
142
|
+
"/legacy_alias",
|
|
143
|
+
];
|
|
144
|
+
let list_paths = ["/legacy_aliases", "/legacy_team_aliases", "/legacy_owner_team_ids", "/aliases"];
|
|
145
|
+
let scalars = scalar_paths
|
|
146
|
+
.into_iter()
|
|
147
|
+
.filter_map(|path| entry.pointer(path).and_then(Value::as_str));
|
|
148
|
+
let lists = list_paths.into_iter().flat_map(|path| {
|
|
149
|
+
entry
|
|
150
|
+
.pointer(path)
|
|
151
|
+
.and_then(Value::as_array)
|
|
152
|
+
.into_iter()
|
|
153
|
+
.flatten()
|
|
154
|
+
.filter_map(Value::as_str)
|
|
155
|
+
});
|
|
156
|
+
scalars
|
|
157
|
+
.chain(lists)
|
|
158
|
+
.filter(|alias| !alias.is_empty())
|
|
159
|
+
.map(str::to_string)
|
|
160
|
+
}
|
|
161
|
+
|
|
46
162
|
/// `compact_team_state`(`state.py:105`):剔除 `teams`(team entry 不嵌套全量 teams),保序。
|
|
47
163
|
pub fn compact_team_state(state: &Value) -> Value {
|
|
48
164
|
match state.as_object() {
|
|
@@ -339,6 +455,14 @@ pub fn resolve_team_scoped_state(
|
|
|
339
455
|
/// 纯 `save_runtime_state`(字节等价);多 team 时把本 team 落到 `teams[target_key]=compact(...)`,顶层
|
|
340
456
|
/// 视图按 golden 的 `existing_primary_key` 逻辑择 incoming/existing。§10:无 unwrap/panic。
|
|
341
457
|
pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<(), StateError> {
|
|
458
|
+
save_team_scoped_state_with_deleted_agents(workspace, team_state, &[])
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
pub(crate) fn save_team_scoped_state_with_deleted_agents(
|
|
462
|
+
workspace: &Path,
|
|
463
|
+
team_state: &Value,
|
|
464
|
+
deleted_agent_ids: &[&str],
|
|
465
|
+
) -> Result<(), StateError> {
|
|
342
466
|
let target_key = team_state_key(team_state);
|
|
343
467
|
let existing = load_runtime_state(workspace)?;
|
|
344
468
|
// existing_primary_key = team_state_key(existing) if existing.get("session_name") else None
|
|
@@ -367,7 +491,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
|
|
|
367
491
|
// not existing_teams and existing_primary_key == target_key → 纯 save(剔 teams)。
|
|
368
492
|
if existing_teams.is_empty() && existing_primary_key.as_deref() == Some(target_key.as_str()) {
|
|
369
493
|
let merged = compact_team_state(team_state);
|
|
370
|
-
return
|
|
494
|
+
return save_runtime_state_with_deleted_agents(workspace, &merged, deleted_agent_ids);
|
|
371
495
|
}
|
|
372
496
|
// teams = deepcopy(incoming_teams or existing_teams)
|
|
373
497
|
let mut teams = match incoming_teams {
|
|
@@ -387,7 +511,7 @@ pub fn save_team_scoped_state(workspace: &Path, team_state: &Value) -> Result<()
|
|
|
387
511
|
if merged.get("teams").and_then(Value::as_object).is_some_and(Map::is_empty) {
|
|
388
512
|
merged.remove("teams");
|
|
389
513
|
}
|
|
390
|
-
|
|
514
|
+
save_runtime_state_with_deleted_agents(workspace, &Value::Object(merged), deleted_agent_ids)
|
|
391
515
|
}
|
|
392
516
|
|
|
393
517
|
// ---- helpers ----
|
|
@@ -98,6 +98,185 @@
|
|
|
98
98
|
items.iter().map(|s| (*s).to_string()).collect()
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
struct EnvGuard {
|
|
102
|
+
saved: Vec<(String, Option<String>)>,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
impl EnvGuard {
|
|
106
|
+
fn apply(vars: &[(&str, Option<&str>)]) -> Self {
|
|
107
|
+
let saved = vars.iter().map(|(k, _)| ((*k).to_string(), std::env::var(k).ok())).collect();
|
|
108
|
+
for (k, v) in vars {
|
|
109
|
+
match v {
|
|
110
|
+
Some(val) => std::env::set_var(k, val),
|
|
111
|
+
None => std::env::remove_var(k),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
Self { saved }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
impl Drop for EnvGuard {
|
|
119
|
+
fn drop(&mut self) {
|
|
120
|
+
for (k, v) in &self.saved {
|
|
121
|
+
match v {
|
|
122
|
+
Some(val) => std::env::set_var(k, val),
|
|
123
|
+
None => std::env::remove_var(k),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#[test]
|
|
130
|
+
#[serial_test::serial(env)]
|
|
131
|
+
fn leader_receiver_endpoint_from_tmux_env_preserves_full_socket_path() {
|
|
132
|
+
let leader_socket = "/tmp/ta-leader-root/tmux-501/dl2f";
|
|
133
|
+
let _env = EnvGuard::apply(&[
|
|
134
|
+
("TMUX", Some("/tmp/ta-leader-root/tmux-501/dl2f,12345,0")),
|
|
135
|
+
("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
assert_eq!(
|
|
139
|
+
super::socket_name_from_tmux_env().as_deref(),
|
|
140
|
+
Some(leader_socket),
|
|
141
|
+
"leader receivers must persist the exact tmux endpoint from $TMUX; a short -L socket \
|
|
142
|
+
name is re-rooted under the coordinator's TMUX_TMPDIR and cannot reach an external \
|
|
143
|
+
leader pane"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#[test]
|
|
148
|
+
#[serial_test::serial(env)]
|
|
149
|
+
fn leader_receiver_endpoint_from_tmux_env_rejects_short_socket_name() {
|
|
150
|
+
let _env = EnvGuard::apply(&[
|
|
151
|
+
("TMUX", Some("dl9aa40c88,12345,0")),
|
|
152
|
+
("TMUX_TMPDIR", Some("/tmp/ta-coordinator-root")),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
assert_eq!(
|
|
156
|
+
super::socket_name_from_tmux_env(),
|
|
157
|
+
None,
|
|
158
|
+
"leader_receiver.tmux_socket is a durable physical endpoint: a short socket name from \
|
|
159
|
+
$TMUX must not be persisted because tmux -L <short> is re-rooted under the coordinator"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[test]
|
|
164
|
+
fn leader_receiver_delivery_uses_full_socket_endpoint_not_short_l_reconstruction() {
|
|
165
|
+
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
166
|
+
let delivery = std::fs::read_to_string(manifest.join("src/messaging/delivery.rs")).unwrap();
|
|
167
|
+
let leader_receiver =
|
|
168
|
+
std::fs::read_to_string(manifest.join("src/messaging/leader_receiver.rs")).unwrap();
|
|
169
|
+
let tmux_backend = std::fs::read_to_string(manifest.join("src/tmux_backend.rs")).unwrap();
|
|
170
|
+
|
|
171
|
+
assert!(
|
|
172
|
+
tmux_backend.contains("\"-S\""),
|
|
173
|
+
"tmux backend must support `tmux -S <full-socket-path>` for persisted external leader \
|
|
174
|
+
endpoints; `-L <short-name>` is not enough when leader and coordinator TMUX_TMPDIR differ"
|
|
175
|
+
);
|
|
176
|
+
assert!(
|
|
177
|
+
!delivery.contains("TmuxBackend::for_socket_name(socket)"),
|
|
178
|
+
"worker->leader delivery must not reconstruct an external leader endpoint with \
|
|
179
|
+
`tmux -L <short-name>`; it must use the persisted full socket path endpoint"
|
|
180
|
+
);
|
|
181
|
+
assert!(
|
|
182
|
+
!leader_receiver.contains("TmuxBackend::for_socket_name(socket)"),
|
|
183
|
+
"leader_receiver live checks must verify the same full socket endpoint used by delivery, \
|
|
184
|
+
not a short socket name resolved under the coordinator's socket root"
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#[test]
|
|
189
|
+
fn leader_receiver_full_endpoint_liveness_list_and_inject_use_s_path_command_shape() {
|
|
190
|
+
let endpoint = "/private/tmp/tmux-501/default";
|
|
191
|
+
let stdout = "%7\tteam-x\t0\tleader\t0\t/dev/ttys003\tbash\t1\t/Users/me/work\t1\t0\n";
|
|
192
|
+
let (be, rec, _stdin) = {
|
|
193
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
194
|
+
let stdin_recorded = Arc::new(Mutex::new(Vec::new()));
|
|
195
|
+
let runner = MockCommandRunner {
|
|
196
|
+
recorded: Arc::clone(&recorded),
|
|
197
|
+
stdin_recorded: Arc::clone(&stdin_recorded),
|
|
198
|
+
queue: Mutex::new(
|
|
199
|
+
vec![
|
|
200
|
+
MockResp::Out(ok(stdout)),
|
|
201
|
+
MockResp::Out(ok("%7\n")),
|
|
202
|
+
MockResp::Out(ok("")),
|
|
203
|
+
MockResp::Out(ok("")),
|
|
204
|
+
MockResp::Out(ok("")),
|
|
205
|
+
]
|
|
206
|
+
.into_iter()
|
|
207
|
+
.collect(),
|
|
208
|
+
),
|
|
209
|
+
default: MockResp::Out(ok("")),
|
|
210
|
+
};
|
|
211
|
+
(
|
|
212
|
+
TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
|
|
213
|
+
recorded,
|
|
214
|
+
stdin_recorded,
|
|
215
|
+
)
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let _ = be.list_targets().expect("list_targets via endpoint");
|
|
219
|
+
let _ = be.liveness(&PaneId::new("%7")).expect("liveness via endpoint");
|
|
220
|
+
let _ = be
|
|
221
|
+
.inject(
|
|
222
|
+
&Target::Pane(PaneId::new("%7")),
|
|
223
|
+
&InjectPayload::Text("hello leader".to_string()),
|
|
224
|
+
Key::Enter,
|
|
225
|
+
true,
|
|
226
|
+
)
|
|
227
|
+
.expect("inject via endpoint");
|
|
228
|
+
|
|
229
|
+
let calls = rec.lock().unwrap().clone();
|
|
230
|
+
assert!(
|
|
231
|
+
calls.len() >= 5,
|
|
232
|
+
"fixture must exercise list-panes, display-message, buffer/paste, and send-keys; got {calls:?}"
|
|
233
|
+
);
|
|
234
|
+
for call in &calls {
|
|
235
|
+
assert!(
|
|
236
|
+
call.starts_with(&["tmux".to_string(), "-S".to_string(), endpoint.to_string()]),
|
|
237
|
+
"leader receiver list/liveness/inject must use tmux -S <full socket path>; got {call:?}"
|
|
238
|
+
);
|
|
239
|
+
assert!(
|
|
240
|
+
!call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()]),
|
|
241
|
+
"leader receiver full endpoint must never be reconstructed with -L; got {call:?}"
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
assert!(
|
|
245
|
+
calls.iter().any(|call| call.iter().any(|arg| arg == "list-panes"))
|
|
246
|
+
&& calls.iter().any(|call| call.iter().any(|arg| arg == "display-message"))
|
|
247
|
+
&& calls.iter().any(|call| call.iter().any(|arg| arg == "paste-buffer"))
|
|
248
|
+
&& calls.iter().any(|call| call.iter().any(|arg| arg == "send-keys")),
|
|
249
|
+
"contract must cover liveness/list/inject command shapes; got {calls:?}"
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
|
|
255
|
+
let endpoint = "dl9aa40c88";
|
|
256
|
+
let (be, rec) = {
|
|
257
|
+
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
258
|
+
let runner = MockCommandRunner {
|
|
259
|
+
recorded: Arc::clone(&recorded),
|
|
260
|
+
stdin_recorded: Arc::new(Mutex::new(Vec::new())),
|
|
261
|
+
queue: Mutex::new(vec![MockResp::Out(ok(""))].into_iter().collect()),
|
|
262
|
+
default: MockResp::Out(ok("")),
|
|
263
|
+
};
|
|
264
|
+
(
|
|
265
|
+
TmuxBackend::with_runner_for_tmux_endpoint(Box::new(runner), endpoint),
|
|
266
|
+
recorded,
|
|
267
|
+
)
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
let _ = be.list_targets().expect("short endpoint should not become -L");
|
|
271
|
+
|
|
272
|
+
let calls = rec.lock().unwrap().clone();
|
|
273
|
+
assert!(
|
|
274
|
+
calls.iter().all(|call| !call.windows(2).any(|w| w == ["-L".to_string(), endpoint.to_string()])),
|
|
275
|
+
"non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
|
|
276
|
+
tmux -L <short> under the coordinator socket root; calls={calls:?}"
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
101
280
|
// ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
|
|
102
281
|
#[test]
|
|
103
282
|
fn has_session_argv_and_exit_code_maps_to_bool() {
|