@team-agent/installer 0.3.7 → 0.3.9
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 +52 -7
- package/crates/team-agent/src/cli/emit.rs +132 -4
- package/crates/team-agent/src/cli/leader.rs +12 -8
- package/crates/team-agent/src/cli/mod.rs +121 -28
- package/crates/team-agent/src/cli/tests/base.rs +14 -0
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
- package/crates/team-agent/src/leader/owner_bind.rs +59 -20
- package/crates/team-agent/src/leader/start.rs +34 -23
- package/crates/team-agent/src/leader/tests/identity.rs +22 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -0
- package/crates/team-agent/src/lifecycle/launch.rs +203 -16
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +25 -12
- package/crates/team-agent/src/lifecycle/restart.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/core.rs +192 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +112 -0
- package/crates/team-agent/src/lifecycle/types.rs +2 -0
- package/crates/team-agent/src/messaging/results.rs +27 -22
- package/crates/team-agent/src/messaging/tests/runtime.rs +18 -0
- package/crates/team-agent/src/provider/adapter.rs +177 -15
- package/crates/team-agent/src/state/identity.rs +29 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
- package/crates/team-agent/src/tmux_backend.rs +90 -7
- package/crates/team-agent/src/transport/test_support.rs +57 -4
- package/crates/team-agent/src/transport.rs +13 -0
- package/npm/install.mjs +31 -35
- package/package.json +4 -4
|
@@ -499,12 +499,14 @@ pub enum QuickStartReport {
|
|
|
499
499
|
session_name: Option<SessionName>,
|
|
500
500
|
state_path: Option<PathBuf>,
|
|
501
501
|
next_actions: Vec<String>,
|
|
502
|
+
attach_commands: Vec<String>,
|
|
502
503
|
},
|
|
503
504
|
/// preflight 阻塞(`quick_start.py:59`)。
|
|
504
505
|
PreflightBlocked {
|
|
505
506
|
summary: String,
|
|
506
507
|
blockers: Vec<String>,
|
|
507
508
|
next_actions: Vec<String>,
|
|
509
|
+
attach_commands: Vec<String>,
|
|
508
510
|
},
|
|
509
511
|
}
|
|
510
512
|
|
|
@@ -689,28 +689,33 @@ pub fn report_result_for_owner_team(
|
|
|
689
689
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
690
690
|
}
|
|
691
691
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
692
|
+
// E15(F4.4 双投修):direct inject 是 deliver **失败时的兜底**,不是无条件第二投。
|
|
693
|
+
// deliver loop 成功(outcome.ok)→ 跳过 direct inject,leader 仅收 deliver 那一条;
|
|
694
|
+
// 全失败 → direct inject 兜底投一条(不丢 result,守 #230/MUST-8)。两全:恰一条。
|
|
695
|
+
if !outcome.ok {
|
|
696
|
+
match inject_leader_notification_direct(workspace, &delivery_state, &content, &message_id) {
|
|
697
|
+
Ok(()) => {
|
|
698
|
+
store.mark(&message_id, "delivered", None)?;
|
|
699
|
+
outcome = crate::messaging::DeliveryOutcome {
|
|
700
|
+
ok: true,
|
|
701
|
+
status: crate::messaging::DeliveryStatus::Delivered,
|
|
702
|
+
message_status: super::helpers::MessageStatusShadow("delivered".to_string()),
|
|
703
|
+
message_id: Some(message_id),
|
|
704
|
+
verification: None,
|
|
705
|
+
stage: None,
|
|
706
|
+
reason: None,
|
|
707
|
+
channel: Some("leader_receiver".to_string()),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
Err(reason) => {
|
|
711
|
+
event_log.write(
|
|
712
|
+
"leader_receiver.direct_inject_skipped",
|
|
713
|
+
serde_json::json!({
|
|
714
|
+
"message_id": message_id,
|
|
715
|
+
"reason": reason,
|
|
716
|
+
}),
|
|
717
|
+
)?;
|
|
718
|
+
}
|
|
714
719
|
}
|
|
715
720
|
}
|
|
716
721
|
}
|
|
@@ -1435,3 +1435,21 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
|
|
|
1435
1435
|
assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
|
|
1436
1436
|
assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader-new"));
|
|
1437
1437
|
}
|
|
1438
|
+
|
|
1439
|
+
// E15 (F4.4 双投修)·源码守卫:report_result 的 direct inject 必须被 `if !outcome.ok` 守为
|
|
1440
|
+
// deliver 失败时的真兜底,绝不在 deliver 成功后无条件再投一次(=用户看到两条同内容回复)。
|
|
1441
|
+
#[test]
|
|
1442
|
+
fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
|
|
1443
|
+
let src = include_str!("../results.rs");
|
|
1444
|
+
// 锁:inject_leader_notification_direct 调用点之前必须有 `if !outcome.ok` 门。
|
|
1445
|
+
let gate = "if !outcome.ok {";
|
|
1446
|
+
let inject_call = "match inject_leader_notification_direct(";
|
|
1447
|
+
let gate_pos = src.find(gate);
|
|
1448
|
+
let inject_pos = src.find(inject_call);
|
|
1449
|
+
assert!(gate_pos.is_some(), "E15: direct inject must be gated by `if !outcome.ok` (deliver-fail fallback)");
|
|
1450
|
+
assert!(inject_pos.is_some(), "inject_leader_notification_direct call site must exist (do NOT delete it; #230 fallback)");
|
|
1451
|
+
assert!(
|
|
1452
|
+
gate_pos.unwrap() < inject_pos.unwrap(),
|
|
1453
|
+
"E15: the `if !outcome.ok` gate must precede the direct-inject call (deliver-success must skip inject → leader gets exactly one copy)"
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
@@ -1118,30 +1118,51 @@ fn scan_copilot_session_store(context: &CaptureSessionContext) -> Vec<CapturedSe
|
|
|
1118
1118
|
) else {
|
|
1119
1119
|
return Vec::new();
|
|
1120
1120
|
};
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
)
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1121
|
+
// E11 层1(本机实锤):copilot honor `--session-id`(sessions.id == 注入的 expected_session_id),
|
|
1122
|
+
// 故 worker 权威 id 可靠在 db。**expected-id 优先点查**:命中即返(High,直接根治 leader/worker
|
|
1123
|
+
// 同 cwd 共享 db 时 latest-wins 误抓 leader 的 bug)。expected 查无 → **不 promote**(E6 铁律:
|
|
1124
|
+
// 不硬写不在盘的假 session),回落 cwd-latest 让收敛重试。
|
|
1125
|
+
if let Some(expected) = context.expected_session_id.as_ref() {
|
|
1126
|
+
let hit: Option<String> = conn
|
|
1127
|
+
.prepare("select id from sessions where id = ?1 limit 1")
|
|
1128
|
+
.ok()
|
|
1129
|
+
.and_then(|mut stmt| {
|
|
1130
|
+
stmt.query_row([expected.as_str()], |row| row.get::<_, String>(0)).ok()
|
|
1131
|
+
});
|
|
1132
|
+
if let Some(session_id) = hit {
|
|
1133
|
+
return vec![copilot_candidate(session_id, &db_path, context)];
|
|
1134
|
+
}
|
|
1135
|
+
// expected 设了但 db 无该 id → 返空(收敛重试),绝不回落抓别人(尤其 leader)的 latest。
|
|
1132
1136
|
return Vec::new();
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1137
|
+
}
|
|
1138
|
+
// 无 expected → **返空**(保守,不 cwd-latest 猜)。
|
|
1139
|
+
// E11 层1 兜底洞(architect 核实):leader+worker 同 cwd 共享 db 时,cwd-latest 可能抓 leader
|
|
1140
|
+
// 的 session;而 allocator 的 claimed 去重只扫 state.agents,**leader 在 state.leader/team_owner
|
|
1141
|
+
// 不在 agents → 兜不住**;且 leader 的 copilot session_id 运行期不入 state(team_owner 只存
|
|
1142
|
+
// leader_session_uuid,非 copilot db id),故无从显式排除。所幸 copilot build_command_plan **总**
|
|
1143
|
+
// 注入 --session-id(expected_session_id 恒 Some)→ 真实 copilot worker 永走上面点查路径,
|
|
1144
|
+
// 此 expected=None 分支对真实 worker 不可达。故直接返空最干净:不猜、绝不把 leader session 分给
|
|
1145
|
+
// worker。db 留 _。
|
|
1146
|
+
let _ = (&db_path, &conn);
|
|
1147
|
+
Vec::new()
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
fn copilot_candidate(
|
|
1151
|
+
session_id: String,
|
|
1152
|
+
db_path: &Path,
|
|
1153
|
+
context: &CaptureSessionContext,
|
|
1154
|
+
) -> CapturedSessionCandidate {
|
|
1155
|
+
CapturedSessionCandidate {
|
|
1135
1156
|
captured: CapturedSession {
|
|
1136
1157
|
session_id: Some(SessionId::new(session_id)),
|
|
1137
|
-
rollout_path: Some(RolloutPath::new(db_path.
|
|
1158
|
+
rollout_path: Some(RolloutPath::new(db_path.to_path_buf())),
|
|
1138
1159
|
captured_via: CaptureVia::FsWatch,
|
|
1139
1160
|
attribution_confidence: Confidence::High,
|
|
1140
1161
|
spawn_cwd: context.spawn_cwd.clone(),
|
|
1141
1162
|
},
|
|
1142
1163
|
positive_agent_id_match: false,
|
|
1143
1164
|
agent_path_match: false,
|
|
1144
|
-
}
|
|
1165
|
+
}
|
|
1145
1166
|
}
|
|
1146
1167
|
|
|
1147
1168
|
fn command_on_path(name: &str) -> bool {
|
|
@@ -1968,4 +1989,145 @@ mod e6_session_attribution_tests {
|
|
|
1968
1989
|
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
|
1969
1990
|
f.set_modified(when).unwrap();
|
|
1970
1991
|
}
|
|
1992
|
+
|
|
1993
|
+
// ── E11 层1:copilot session 归因(expected-id 优先,不抓 leader latest)──
|
|
1994
|
+
struct HomeGuard {
|
|
1995
|
+
prev: Option<std::ffi::OsString>,
|
|
1996
|
+
}
|
|
1997
|
+
impl HomeGuard {
|
|
1998
|
+
fn set(home: &Path) -> Self {
|
|
1999
|
+
let prev = std::env::var_os("HOME");
|
|
2000
|
+
std::env::set_var("HOME", home);
|
|
2001
|
+
Self { prev }
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
impl Drop for HomeGuard {
|
|
2005
|
+
fn drop(&mut self) {
|
|
2006
|
+
match &self.prev {
|
|
2007
|
+
Some(v) => std::env::set_var("HOME", v),
|
|
2008
|
+
None => std::env::remove_var("HOME"),
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/// 造一个 copilot session-store.db,写 (id, cwd, updated_at) 行。
|
|
2014
|
+
fn seed_copilot_db(home: &Path, rows: &[(&str, &str, i64)]) {
|
|
2015
|
+
let dir = home.join(".copilot");
|
|
2016
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
2017
|
+
let conn = rusqlite::Connection::open(dir.join("session-store.db")).unwrap();
|
|
2018
|
+
conn.execute(
|
|
2019
|
+
"create table sessions (id text primary key, cwd text, updated_at integer)",
|
|
2020
|
+
[],
|
|
2021
|
+
)
|
|
2022
|
+
.unwrap();
|
|
2023
|
+
for (id, cwd, updated) in rows {
|
|
2024
|
+
conn.execute(
|
|
2025
|
+
"insert into sessions (id, cwd, updated_at) values (?1, ?2, ?3)",
|
|
2026
|
+
rusqlite::params![id, cwd, updated],
|
|
2027
|
+
)
|
|
2028
|
+
.unwrap();
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
#[test]
|
|
2033
|
+
#[serial_test::serial(env)]
|
|
2034
|
+
fn copilot_expected_id_wins_over_leader_latest_same_cwd() {
|
|
2035
|
+
// 真机复现的确定性 fixture:leader row updated 晚(latest),worker row id == expected。
|
|
2036
|
+
// capture 必返 worker 自己的 id,不返 leader 的 latest。
|
|
2037
|
+
let base = tmp_root("e11-copilot");
|
|
2038
|
+
let home = base.join("home");
|
|
2039
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
2040
|
+
let cwd = base.join("ws");
|
|
2041
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
2042
|
+
let worker_id = "1142c4c2-0000-4000-8000-000000000001";
|
|
2043
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
2044
|
+
// leader updated_at 更大(latest-wins 会抓它);worker 更早。
|
|
2045
|
+
seed_copilot_db(
|
|
2046
|
+
&home,
|
|
2047
|
+
&[
|
|
2048
|
+
(worker_id, &cwd.to_string_lossy(), 100),
|
|
2049
|
+
(leader_id, &cwd.to_string_lossy(), 999),
|
|
2050
|
+
],
|
|
2051
|
+
);
|
|
2052
|
+
let _h = HomeGuard::set(&home);
|
|
2053
|
+
let ctx = CaptureSessionContext {
|
|
2054
|
+
agent_id: "worker".to_string(),
|
|
2055
|
+
spawn_cwd: cwd.clone(),
|
|
2056
|
+
pane_id: None,
|
|
2057
|
+
pane_pid: None,
|
|
2058
|
+
spawned_at: None,
|
|
2059
|
+
expected_session_id: Some(SessionId::new(worker_id)),
|
|
2060
|
+
provider_projects_root: None,
|
|
2061
|
+
};
|
|
2062
|
+
let out = scan_copilot_session_store(&ctx);
|
|
2063
|
+
assert_eq!(out.len(), 1, "expected-id point query → single authoritative candidate");
|
|
2064
|
+
assert_eq!(
|
|
2065
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
2066
|
+
worker_id,
|
|
2067
|
+
"must return worker's own (expected) session, NOT leader's latest"
|
|
2068
|
+
);
|
|
2069
|
+
drop(_h);
|
|
2070
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
#[test]
|
|
2074
|
+
#[serial_test::serial(env)]
|
|
2075
|
+
fn copilot_expected_id_absent_in_db_returns_empty_not_leader() {
|
|
2076
|
+
// expected 设了但 db 无该 id(会话还没落)→ 返空(收敛重试),绝不回落抓 leader latest。
|
|
2077
|
+
let base = tmp_root("e11-copilot-absent");
|
|
2078
|
+
let home = base.join("home");
|
|
2079
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
2080
|
+
let cwd = base.join("ws");
|
|
2081
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
2082
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
2083
|
+
seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
|
|
2084
|
+
let _h = HomeGuard::set(&home);
|
|
2085
|
+
let ctx = CaptureSessionContext {
|
|
2086
|
+
agent_id: "worker".to_string(),
|
|
2087
|
+
spawn_cwd: cwd.clone(),
|
|
2088
|
+
pane_id: None,
|
|
2089
|
+
pane_pid: None,
|
|
2090
|
+
spawned_at: None,
|
|
2091
|
+
expected_session_id: Some(SessionId::new("1142c4c2-0000-4000-8000-000000000001")),
|
|
2092
|
+
provider_projects_root: None,
|
|
2093
|
+
};
|
|
2094
|
+
let out = scan_copilot_session_store(&ctx);
|
|
2095
|
+
assert!(
|
|
2096
|
+
out.is_empty(),
|
|
2097
|
+
"expected id absent in db → empty (no promote, no leader latest); got {out:?}"
|
|
2098
|
+
);
|
|
2099
|
+
drop(_h);
|
|
2100
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
#[test]
|
|
2104
|
+
#[serial_test::serial(env)]
|
|
2105
|
+
fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
|
|
2106
|
+
// E11 层1 兜底洞(architect):无 expected + 同 cwd 仅 leader row → 必返空,绝不返 leader。
|
|
2107
|
+
// (真实 copilot worker 恒有 expected,此分支不可达;保守返空堵住 allocator 不排除 leader 的洞。)
|
|
2108
|
+
let base = tmp_root("e11-copilot-noexp");
|
|
2109
|
+
let home = base.join("home");
|
|
2110
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
2111
|
+
let cwd = base.join("ws");
|
|
2112
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
2113
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
2114
|
+
seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
|
|
2115
|
+
let _h = HomeGuard::set(&home);
|
|
2116
|
+
let ctx = CaptureSessionContext {
|
|
2117
|
+
agent_id: "worker".to_string(),
|
|
2118
|
+
spawn_cwd: cwd.clone(),
|
|
2119
|
+
pane_id: None,
|
|
2120
|
+
pane_pid: None,
|
|
2121
|
+
spawned_at: None,
|
|
2122
|
+
expected_session_id: None, // 无 expected → 兜底路径
|
|
2123
|
+
provider_projects_root: None,
|
|
2124
|
+
};
|
|
2125
|
+
let out = scan_copilot_session_store(&ctx);
|
|
2126
|
+
assert!(
|
|
2127
|
+
out.is_empty(),
|
|
2128
|
+
"no expected + only leader row in same cwd → must return empty, NOT leader; got {out:?}"
|
|
2129
|
+
);
|
|
2130
|
+
drop(_h);
|
|
2131
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
2132
|
+
}
|
|
1971
2133
|
}
|
|
@@ -422,6 +422,7 @@ fn py_repr_str(s: &str) -> String {
|
|
|
422
422
|
mod tests {
|
|
423
423
|
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
|
424
424
|
use super::*;
|
|
425
|
+
use serial_test::serial;
|
|
425
426
|
use std::collections::HashMap;
|
|
426
427
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
427
428
|
|
|
@@ -446,6 +447,32 @@ mod tests {
|
|
|
446
447
|
ws
|
|
447
448
|
}
|
|
448
449
|
|
|
450
|
+
struct EnvUnsetGuard {
|
|
451
|
+
previous: Vec<(&'static str, Option<String>)>,
|
|
452
|
+
}
|
|
453
|
+
impl EnvUnsetGuard {
|
|
454
|
+
fn unset(keys: &[&'static str]) -> Self {
|
|
455
|
+
let previous = keys
|
|
456
|
+
.iter()
|
|
457
|
+
.map(|key| (*key, std::env::var(key).ok()))
|
|
458
|
+
.collect::<Vec<_>>();
|
|
459
|
+
for key in keys {
|
|
460
|
+
std::env::remove_var(key);
|
|
461
|
+
}
|
|
462
|
+
Self { previous }
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
impl Drop for EnvUnsetGuard {
|
|
466
|
+
fn drop(&mut self) {
|
|
467
|
+
for (key, value) in self.previous.drain(..).rev() {
|
|
468
|
+
match value {
|
|
469
|
+
Some(value) => std::env::set_var(key, value),
|
|
470
|
+
None => std::env::remove_var(key),
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
449
476
|
#[test]
|
|
450
477
|
fn os_user_and_fingerprint() {
|
|
451
478
|
assert_eq!(identity_os_user(&env(&[("USER", "alice")])), "alice");
|
|
@@ -560,7 +587,9 @@ mod tests {
|
|
|
560
587
|
}
|
|
561
588
|
|
|
562
589
|
#[test]
|
|
590
|
+
#[serial(env)]
|
|
563
591
|
fn apply_first_time_binding_success_writes_state() {
|
|
592
|
+
let _env = EnvUnsetGuard::unset(&["TMUX", "TMUX_PANE"]);
|
|
564
593
|
let ws = temp_ws();
|
|
565
594
|
let now = "2026-06-02T09:17:59.994383+00:00";
|
|
566
595
|
let mut state = json!({});
|
|
@@ -503,6 +503,50 @@
|
|
|
503
503
|
);
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
#[test]
|
|
507
|
+
fn has_pane_is_direct_existence_probe_not_liveness_guess() {
|
|
508
|
+
let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
|
|
509
|
+
assert_eq!(be.has_pane(&PaneId::new("%7")).expect("has_pane"), Some(true));
|
|
510
|
+
let argv0 = rec.lock().unwrap()[0].clone();
|
|
511
|
+
assert!(
|
|
512
|
+
argv0.contains(&"display-message".to_string())
|
|
513
|
+
&& argv0.iter().any(|x| x.contains("#{pane_id}"))
|
|
514
|
+
&& argv0.contains(&"%7".to_string()),
|
|
515
|
+
"has_pane must use the cheap display-message #{{pane_id}} probe; got {argv0:?}"
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
let (be, _r) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
519
|
+
assert_eq!(
|
|
520
|
+
be.has_pane(&PaneId::new("%9999")).expect("has_pane"),
|
|
521
|
+
Some(false),
|
|
522
|
+
"real tmux can report a missing pane as exit 0 with empty stdout"
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane: %9999")), vec![]);
|
|
526
|
+
assert_eq!(be.has_pane(&PaneId::new("%9999")).expect("has_pane"), Some(false));
|
|
527
|
+
|
|
528
|
+
let (be, _r) = backend_with(MockResp::Out(ok("%8")), vec![]);
|
|
529
|
+
assert_eq!(
|
|
530
|
+
be.has_pane(&PaneId::new("%7")).expect("has_pane"),
|
|
531
|
+
None,
|
|
532
|
+
"a successful but mismatched pane id is not proof that the requested pane exists"
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
let (be, _r) = backend_with(MockResp::Out(ok("not-a-pane")), vec![]);
|
|
536
|
+
assert_eq!(
|
|
537
|
+
be.has_pane(&PaneId::new("%7")).expect("has_pane"),
|
|
538
|
+
None,
|
|
539
|
+
"a successful but invalid pane id stays Unknown"
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
let (be, _r) = backend_with(MockResp::Out(fail(1, "error connecting to server: No such file or directory")), vec![]);
|
|
543
|
+
assert_eq!(
|
|
544
|
+
be.has_pane(&PaneId::new("%7")).expect("has_pane"),
|
|
545
|
+
None,
|
|
546
|
+
"server/probe errors remain Unknown, not absent"
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
506
550
|
// ── CP-1: per-team socket — for_workspace injects `-L ta-<hash>` at the run chokepoint; new() does NOT ─
|
|
507
551
|
#[test]
|
|
508
552
|
fn for_workspace_backend_injects_per_team_socket_but_default_backend_does_not() {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
use std::collections::BTreeMap;
|
|
19
19
|
use std::hash::{Hash, Hasher};
|
|
20
20
|
use std::io::{Read, Write};
|
|
21
|
+
use std::os::unix::fs::FileTypeExt;
|
|
21
22
|
use std::path::{Path, PathBuf};
|
|
22
23
|
use std::process::Stdio;
|
|
23
24
|
use std::time::{Duration, Instant};
|
|
@@ -331,6 +332,20 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
|
|
|
331
332
|
}
|
|
332
333
|
|
|
333
334
|
pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
335
|
+
if let Some(existing) = existing_socket_path_for_workspace(workspace) {
|
|
336
|
+
return Some(existing);
|
|
337
|
+
}
|
|
338
|
+
let uid = unsafe { libc::geteuid() };
|
|
339
|
+
let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
|
|
340
|
+
let default_root = default_root.canonicalize().unwrap_or(default_root);
|
|
341
|
+
Some(default_root.join(socket_name_for_workspace(workspace)))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
|
|
345
|
+
existing_socket_path_for_workspace(workspace).is_none()
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
334
349
|
let socket_name = socket_name_for_workspace(workspace);
|
|
335
350
|
let roots = tmux_socket_roots();
|
|
336
351
|
for root in &roots {
|
|
@@ -340,12 +355,19 @@ pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
|
340
355
|
return Some(candidate.canonicalize().unwrap_or(candidate));
|
|
341
356
|
}
|
|
342
357
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
358
|
+
None
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
pub(crate) fn socket_missing_hint_for_workspace(workspace: &Path) -> String {
|
|
362
|
+
let socket_name = socket_name_for_workspace(workspace);
|
|
363
|
+
let roots = tmux_socket_roots()
|
|
364
|
+
.into_iter()
|
|
365
|
+
.map(|root| root.display().to_string())
|
|
366
|
+
.collect::<Vec<_>>()
|
|
367
|
+
.join(", ");
|
|
368
|
+
format!(
|
|
369
|
+
"tmux socket {socket_name} not found under [{roots}]; run `team-agent attach-leader` or restart the team before attaching"
|
|
370
|
+
)
|
|
349
371
|
}
|
|
350
372
|
|
|
351
373
|
pub(crate) fn attach_command_for_workspace(
|
|
@@ -373,7 +395,7 @@ pub(crate) fn attach_commands_for_windows<'a>(
|
|
|
373
395
|
.collect()
|
|
374
396
|
}
|
|
375
397
|
|
|
376
|
-
fn tmux_socket_roots() -> Vec<PathBuf> {
|
|
398
|
+
pub(crate) fn tmux_socket_roots() -> Vec<PathBuf> {
|
|
377
399
|
let uid = unsafe { libc::geteuid() };
|
|
378
400
|
let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
|
|
379
401
|
if let Some(tmpdir) = std::env::var_os("TMPDIR") {
|
|
@@ -384,6 +406,29 @@ fn tmux_socket_roots() -> Vec<PathBuf> {
|
|
|
384
406
|
roots
|
|
385
407
|
}
|
|
386
408
|
|
|
409
|
+
pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
|
|
410
|
+
let mut endpoints = Vec::new();
|
|
411
|
+
for root in tmux_socket_roots() {
|
|
412
|
+
let Ok(entries) = std::fs::read_dir(root) else {
|
|
413
|
+
continue;
|
|
414
|
+
};
|
|
415
|
+
for entry in entries.flatten() {
|
|
416
|
+
let Ok(file_type) = entry.file_type() else {
|
|
417
|
+
continue;
|
|
418
|
+
};
|
|
419
|
+
if !file_type.is_socket() {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
let path = entry.path();
|
|
423
|
+
let path = path.canonicalize().unwrap_or(path);
|
|
424
|
+
endpoints.push(path.to_string_lossy().to_string());
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
endpoints.sort();
|
|
428
|
+
endpoints.dedup();
|
|
429
|
+
endpoints
|
|
430
|
+
}
|
|
431
|
+
|
|
387
432
|
pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
|
|
388
433
|
let tmux = std::env::var("TMUX")
|
|
389
434
|
.ok()
|
|
@@ -673,6 +718,10 @@ impl Transport for TmuxBackend {
|
|
|
673
718
|
BackendKind::Tmux
|
|
674
719
|
}
|
|
675
720
|
|
|
721
|
+
fn probes_real_tmux_socket_roots(&self) -> bool {
|
|
722
|
+
true
|
|
723
|
+
}
|
|
724
|
+
|
|
676
725
|
fn spawn_first(
|
|
677
726
|
&self,
|
|
678
727
|
session: &SessionName,
|
|
@@ -859,6 +908,40 @@ impl Transport for TmuxBackend {
|
|
|
859
908
|
}
|
|
860
909
|
}
|
|
861
910
|
|
|
911
|
+
fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
|
|
912
|
+
let argv = self.tmux_argv(&[
|
|
913
|
+
"tmux".to_string(),
|
|
914
|
+
"display-message".to_string(),
|
|
915
|
+
"-p".to_string(),
|
|
916
|
+
"-t".to_string(),
|
|
917
|
+
pane.as_str().to_string(),
|
|
918
|
+
"#{pane_id}".to_string(),
|
|
919
|
+
]);
|
|
920
|
+
let output = self.runner.run(&argv)?;
|
|
921
|
+
if output.success {
|
|
922
|
+
let pane_id = output.stdout.trim();
|
|
923
|
+
if pane_id.is_empty() {
|
|
924
|
+
return Ok(Some(false));
|
|
925
|
+
}
|
|
926
|
+
if pane_id == pane.as_str()
|
|
927
|
+
&& pane_id.starts_with('%')
|
|
928
|
+
&& pane_id[1..].chars().all(|ch| ch.is_ascii_digit())
|
|
929
|
+
{
|
|
930
|
+
return Ok(Some(true));
|
|
931
|
+
}
|
|
932
|
+
return Ok(None);
|
|
933
|
+
}
|
|
934
|
+
let stderr = output.stderr.to_ascii_lowercase();
|
|
935
|
+
if stderr.contains("can't find pane")
|
|
936
|
+
|| stderr.contains("no such pane")
|
|
937
|
+
|| (stderr.contains("can't find") && stderr.contains("pane"))
|
|
938
|
+
{
|
|
939
|
+
Ok(Some(false))
|
|
940
|
+
} else {
|
|
941
|
+
Ok(None)
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
862
945
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
863
946
|
// P5 (C-P5-3): `#{pane_pid}` rides the single list-panes call (field index 11),
|
|
864
947
|
// killing the per-pane display-message N+1 fallback.
|
|
@@ -18,18 +18,39 @@ pub struct SpawnRecord {
|
|
|
18
18
|
pub argv: Vec<String>,
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
#[derive(Debug, Clone
|
|
21
|
+
#[derive(Debug, Clone)]
|
|
22
22
|
struct OfflineState {
|
|
23
23
|
session_present: bool,
|
|
24
24
|
session_absent_after_spawn_first: bool,
|
|
25
25
|
targets: Vec<PaneInfo>,
|
|
26
26
|
windows: Vec<WindowName>,
|
|
27
|
+
pane_presence: BTreeMap<String, bool>,
|
|
28
|
+
liveness: BTreeMap<String, PaneLiveness>,
|
|
29
|
+
default_liveness: PaneLiveness,
|
|
27
30
|
calls: Vec<&'static str>,
|
|
28
31
|
spawns: Vec<SpawnRecord>,
|
|
29
32
|
inject_targets: Vec<Target>,
|
|
30
33
|
inject_payloads: Vec<String>,
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
impl Default for OfflineState {
|
|
37
|
+
fn default() -> Self {
|
|
38
|
+
Self {
|
|
39
|
+
session_present: false,
|
|
40
|
+
session_absent_after_spawn_first: false,
|
|
41
|
+
targets: Vec::new(),
|
|
42
|
+
windows: Vec::new(),
|
|
43
|
+
pane_presence: BTreeMap::new(),
|
|
44
|
+
liveness: BTreeMap::new(),
|
|
45
|
+
default_liveness: PaneLiveness::Unknown,
|
|
46
|
+
calls: Vec::new(),
|
|
47
|
+
spawns: Vec::new(),
|
|
48
|
+
inject_targets: Vec::new(),
|
|
49
|
+
inject_payloads: Vec::new(),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
33
54
|
#[derive(Debug, Clone, Default)]
|
|
34
55
|
pub struct OfflineTransport {
|
|
35
56
|
inner: Arc<Mutex<OfflineState>>,
|
|
@@ -60,6 +81,25 @@ impl OfflineTransport {
|
|
|
60
81
|
self
|
|
61
82
|
}
|
|
62
83
|
|
|
84
|
+
pub fn with_default_liveness(self, liveness: PaneLiveness) -> Self {
|
|
85
|
+
self.with_state(|state| state.default_liveness = liveness);
|
|
86
|
+
self
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pub fn with_liveness(self, pane: impl Into<String>, liveness: PaneLiveness) -> Self {
|
|
90
|
+
self.with_state(|state| {
|
|
91
|
+
state.liveness.insert(pane.into(), liveness);
|
|
92
|
+
});
|
|
93
|
+
self
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pub fn with_pane_presence(self, pane: impl Into<String>, present: bool) -> Self {
|
|
97
|
+
self.with_state(|state| {
|
|
98
|
+
state.pane_presence.insert(pane.into(), present);
|
|
99
|
+
});
|
|
100
|
+
self
|
|
101
|
+
}
|
|
102
|
+
|
|
63
103
|
pub fn calls(&self) -> Vec<&'static str> {
|
|
64
104
|
self.with_state(|state| state.calls.clone())
|
|
65
105
|
}
|
|
@@ -198,9 +238,22 @@ impl Transport for OfflineTransport {
|
|
|
198
238
|
Ok(None)
|
|
199
239
|
}
|
|
200
240
|
|
|
201
|
-
fn liveness(&self,
|
|
202
|
-
self.
|
|
203
|
-
|
|
241
|
+
fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
242
|
+
Ok(self.with_state(|state| {
|
|
243
|
+
state.calls.push("liveness");
|
|
244
|
+
state
|
|
245
|
+
.liveness
|
|
246
|
+
.get(pane.as_str())
|
|
247
|
+
.copied()
|
|
248
|
+
.unwrap_or(state.default_liveness)
|
|
249
|
+
}))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
|
|
253
|
+
Ok(self.with_state(|state| {
|
|
254
|
+
state.calls.push("has_pane");
|
|
255
|
+
state.pane_presence.get(pane.as_str()).copied()
|
|
256
|
+
}))
|
|
204
257
|
}
|
|
205
258
|
|
|
206
259
|
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
@@ -399,6 +399,12 @@ pub trait Transport: Send + Sync {
|
|
|
399
399
|
/// 后端种类(诊断/事件用)。
|
|
400
400
|
fn kind(&self) -> BackendKind;
|
|
401
401
|
|
|
402
|
+
/// Only the concrete tmux backend should scan real tmux socket roots.
|
|
403
|
+
/// Test doubles stay hermetic and use their injected probe results.
|
|
404
|
+
fn probes_real_tmux_socket_roots(&self) -> bool {
|
|
405
|
+
false
|
|
406
|
+
}
|
|
407
|
+
|
|
402
408
|
// —— SPAWN(ST):所有后端天然满足;cwd/env 是 spawn 参数,无独立动词(§gap-setenv)——
|
|
403
409
|
|
|
404
410
|
/// tmux=`new-session -d` / wezterm=`spawn --new-window` / conpty=`openpty`+spawn。
|
|
@@ -481,6 +487,13 @@ pub trait Transport: Send + Sync {
|
|
|
481
487
|
/// pane 存活三态(`PaneLiveness`,bug-085 穷尽 match;unknown ≠ dead ≠ live)。
|
|
482
488
|
fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError>;
|
|
483
489
|
|
|
490
|
+
/// Cheap direct pane existence check when a backend can prove it. `Ok(None)`
|
|
491
|
+
/// preserves the existing Unknown boundary.
|
|
492
|
+
fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
|
|
493
|
+
let _ = pane;
|
|
494
|
+
Ok(None)
|
|
495
|
+
}
|
|
496
|
+
|
|
484
497
|
// —— ENUMERATE / IDENTITY(SL + 进程探测):身份/rebind 地基 ——
|
|
485
498
|
|
|
486
499
|
/// 全局枚举所有 pane + 每 pane 的 leader_env。tmux=`list-panes -a` + 读进程 env;
|