@team-agent/installer 0.3.18 → 0.3.20
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/emit.rs +4 -3
- package/crates/team-agent/src/cli/leader.rs +15 -1
- package/crates/team-agent/src/cli/tests/base.rs +15 -14
- package/crates/team-agent/src/cli/tests/status_send.rs +42 -0
- package/crates/team-agent/src/cli/types.rs +6 -0
- package/crates/team-agent/src/coordinator/backoff.rs +47 -13
- package/crates/team-agent/src/coordinator/tests/daemon.rs +150 -0
- package/crates/team-agent/src/leader/lease.rs +3 -4
- package/crates/team-agent/src/leader/start.rs +70 -7
- package/crates/team-agent/src/leader/tests/identity.rs +10 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +22 -7
- package/crates/team-agent/src/lifecycle/restart/common.rs +32 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +4 -2
- package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +222 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +21 -0
- package/crates/team-agent/src/tmux_backend.rs +118 -9
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -32,9 +32,10 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
|
|
|
32
32
|
return emit_missing_subcommand_usage();
|
|
33
33
|
};
|
|
34
34
|
if command == "codex" || command == "claude" || command == "copilot" {
|
|
35
|
-
return cmd_leader_passthrough(command, &argv[1..], cwd)
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
return match cmd_leader_passthrough(command, &argv[1..], cwd) {
|
|
36
|
+
Ok(result) => emit_result(result),
|
|
37
|
+
Err(error) => emit_cli_error(command, &argv[1..], cwd, &error),
|
|
38
|
+
};
|
|
38
39
|
}
|
|
39
40
|
if matches!(command, "-h" | "--help" | "help") {
|
|
40
41
|
println!("{}", command_help(None));
|
|
@@ -23,7 +23,14 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
|
|
|
23
23
|
while idx < values.len() {
|
|
24
24
|
let token = &values[idx];
|
|
25
25
|
if token == "--" {
|
|
26
|
-
|
|
26
|
+
for value in values.iter().skip(idx + 1) {
|
|
27
|
+
if is_leader_launcher_flag(value) {
|
|
28
|
+
return Err(CliError::Runtime(format!(
|
|
29
|
+
"Team Agent launcher flag {value} must appear before --"
|
|
30
|
+
)));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
out.provider_args.extend(values.iter().skip(idx + 1).cloned());
|
|
27
34
|
break;
|
|
28
35
|
} else if token == "--attach" || token == "--attach-existing" {
|
|
29
36
|
out.attach_existing = true;
|
|
@@ -49,6 +56,13 @@ pub fn leader_launcher_args(values: &[String]) -> Result<LeaderLauncherArgs, Cli
|
|
|
49
56
|
Ok(out)
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
fn is_leader_launcher_flag(value: &str) -> bool {
|
|
60
|
+
matches!(
|
|
61
|
+
value,
|
|
62
|
+
"--external-leader" | "--attach" | "--attach-existing" | "--confirm" | "--attach-session"
|
|
63
|
+
) || value.starts_with("--attach-session=")
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
fn leader_launcher_json(values: &[String]) -> bool {
|
|
53
67
|
values
|
|
54
68
|
.iter()
|
|
@@ -86,17 +86,18 @@ use super::*;
|
|
|
86
86
|
assert!(!got.attach_existing);
|
|
87
87
|
assert_eq!(
|
|
88
88
|
got.provider_args,
|
|
89
|
-
vec!["--
|
|
89
|
+
vec!["--model".to_string(), "opus".to_string()]
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
#[test]
|
|
94
|
-
fn
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
fn leader_launcher_args_external_leader_after_dashdash_errors() {
|
|
95
|
+
let err = leader_launcher_args(&["--".into(), "--external-leader".into()])
|
|
96
|
+
.expect_err("Team Agent flags after -- must not be silently passed to provider");
|
|
97
|
+
assert!(
|
|
98
|
+
err.to_string()
|
|
99
|
+
.contains("Team Agent launcher flag --external-leader must appear before --"),
|
|
100
|
+
"unexpected error: {err}"
|
|
100
101
|
);
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -116,22 +117,22 @@ use super::*;
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
#[test]
|
|
119
|
-
fn
|
|
120
|
-
//
|
|
121
|
-
// provider_args=["
|
|
122
|
-
//
|
|
120
|
+
fn leader_launcher_args_dashdash_passthrough_strips_separator() {
|
|
121
|
+
// ["--attach","--","-x","--provider-confirm"] ->
|
|
122
|
+
// provider_args=["-x","--provider-confirm"], attach_existing=True, confirm_attach=False
|
|
123
|
+
// Known Team Agent launcher flags after `--` are rejected by a separate guard.
|
|
123
124
|
let got = leader_launcher_args(&[
|
|
124
125
|
"--attach".into(),
|
|
125
126
|
"--".into(),
|
|
126
127
|
"-x".into(),
|
|
127
|
-
"--confirm".into(),
|
|
128
|
+
"--provider-confirm".into(),
|
|
128
129
|
])
|
|
129
130
|
.unwrap();
|
|
130
131
|
assert!(got.attach_existing);
|
|
131
|
-
assert!(!got.confirm_attach
|
|
132
|
+
assert!(!got.confirm_attach);
|
|
132
133
|
assert_eq!(
|
|
133
134
|
got.provider_args,
|
|
134
|
-
vec!["
|
|
135
|
+
vec!["-x".to_string(), "--provider-confirm".to_string()]
|
|
135
136
|
);
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -415,6 +415,48 @@ fn run_send_unknown_task_renders_error_not_silent_swallow() {
|
|
|
415
415
|
let _ = std::fs::remove_dir_all(&ws);
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
#[test]
|
|
419
|
+
fn run_leader_passthrough_flag_after_dashdash_renders_error_not_silent_swallow() {
|
|
420
|
+
let ws = std::env::temp_dir().join(format!(
|
|
421
|
+
"ta-run-leaderflag-{}-{}",
|
|
422
|
+
std::process::id(),
|
|
423
|
+
std::time::SystemTime::now()
|
|
424
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
425
|
+
.unwrap()
|
|
426
|
+
.as_nanos()
|
|
427
|
+
));
|
|
428
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
429
|
+
let argv: Vec<String> = ["codex", "--", "--external-leader"]
|
|
430
|
+
.iter()
|
|
431
|
+
.map(ToString::to_string)
|
|
432
|
+
.collect();
|
|
433
|
+
|
|
434
|
+
let code = run(&argv, &ws);
|
|
435
|
+
|
|
436
|
+
assert_eq!(
|
|
437
|
+
code,
|
|
438
|
+
ExitCode::Error,
|
|
439
|
+
"misplaced leader flag must fail before provider exec"
|
|
440
|
+
);
|
|
441
|
+
let logs_dir = ws.join(".team").join("logs");
|
|
442
|
+
let mut found = String::new();
|
|
443
|
+
if let Ok(entries) = std::fs::read_dir(&logs_dir) {
|
|
444
|
+
for entry in entries.flatten() {
|
|
445
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
446
|
+
if name.starts_with("cli-error-") {
|
|
447
|
+
found = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
assert!(
|
|
453
|
+
found.contains("Team Agent launcher flag --external-leader must appear before --"),
|
|
454
|
+
"leader passthrough run() must render the misplaced flag error; a silent swallow leaves no \
|
|
455
|
+
cli-error log. got log body: {found:?}"
|
|
456
|
+
);
|
|
457
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
458
|
+
}
|
|
459
|
+
|
|
418
460
|
// R8 D6 (c-lite offline byte-lock): the CLI requeued_exhausted_watchers return projection, extracted
|
|
419
461
|
// into a pure helper, must project the golden event's watcher_ids STRING list (leader/__init__.py:56) —
|
|
420
462
|
// NOT the Rust `requeued` Vec<WatcherNotice> objects.
|
|
@@ -101,6 +101,12 @@ impl CliError {
|
|
|
101
101
|
.to_string(),
|
|
102
102
|
]);
|
|
103
103
|
}
|
|
104
|
+
} else if error.contains("Team Agent launcher flag")
|
|
105
|
+
&& error.contains("must appear before --")
|
|
106
|
+
{
|
|
107
|
+
payload.action = String::from(
|
|
108
|
+
"move the Team Agent launcher flag before `--`; only provider flags belong after `--`",
|
|
109
|
+
);
|
|
104
110
|
}
|
|
105
111
|
payload
|
|
106
112
|
}
|
|
@@ -34,21 +34,42 @@ pub struct DaemonArgs {
|
|
|
34
34
|
/// §10:返 `Result`(顶层 bin 用 anyhow 收;§12 边界)。
|
|
35
35
|
pub fn run_daemon(args: DaemonArgs) -> Result<(), DaemonError> {
|
|
36
36
|
// CP-1: the daemon's whole tick surface (has_session / capture / inject / list_windows / kill)
|
|
37
|
-
// runs through this backend
|
|
38
|
-
//
|
|
37
|
+
// runs through this backend. Prefer the persisted runtime endpoint so attached explicit-socket
|
|
38
|
+
// teams are checked on the same socket as lifecycle worker operations.
|
|
39
|
+
let state = crate::state::persist::load_runtime_state(args.workspace.as_path()).ok();
|
|
40
|
+
let tmux_selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
|
|
41
|
+
args.workspace.as_path(),
|
|
42
|
+
state.as_ref(),
|
|
43
|
+
);
|
|
44
|
+
let tmux_metadata = DaemonTmuxEndpointMetadata {
|
|
45
|
+
tmux_endpoint_used: tmux_selection.tmux_endpoint_used.clone(),
|
|
46
|
+
tmux_endpoint_source: tmux_selection.tmux_endpoint_source.as_str(),
|
|
47
|
+
};
|
|
39
48
|
let coordinator = Coordinator::new(
|
|
40
49
|
args.workspace.clone(),
|
|
41
50
|
Box::new(RealProviderRegistry),
|
|
42
|
-
Box::new(
|
|
43
|
-
args.workspace.as_path(),
|
|
44
|
-
)),
|
|
51
|
+
Box::new(tmux_selection.backend),
|
|
45
52
|
);
|
|
46
|
-
|
|
53
|
+
run_daemon_with_coordinator_and_boot_tmux(&args, &coordinator, Some(tmux_metadata))
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
pub fn run_daemon_with_coordinator(
|
|
50
57
|
args: &DaemonArgs,
|
|
51
58
|
coordinator: &Coordinator,
|
|
59
|
+
) -> Result<(), DaemonError> {
|
|
60
|
+
run_daemon_with_coordinator_and_boot_tmux(args, coordinator, None)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[derive(Debug, Clone)]
|
|
64
|
+
struct DaemonTmuxEndpointMetadata {
|
|
65
|
+
tmux_endpoint_used: Option<String>,
|
|
66
|
+
tmux_endpoint_source: &'static str,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fn run_daemon_with_coordinator_and_boot_tmux(
|
|
70
|
+
args: &DaemonArgs,
|
|
71
|
+
coordinator: &Coordinator,
|
|
72
|
+
tmux_metadata: Option<DaemonTmuxEndpointMetadata>,
|
|
52
73
|
) -> Result<(), DaemonError> {
|
|
53
74
|
let runtime_dir = crate::model::paths::runtime_dir(args.workspace.as_path());
|
|
54
75
|
std::fs::create_dir_all(&runtime_dir)?;
|
|
@@ -57,13 +78,26 @@ pub fn run_daemon_with_coordinator(
|
|
|
57
78
|
write_coordinator_metadata(&args.workspace, pid, MetadataSource::Boot)?;
|
|
58
79
|
|
|
59
80
|
let event_log = EventLog::new(args.workspace.as_path());
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
let mut boot_event = serde_json::json!({
|
|
82
|
+
"workspace": args.workspace.as_path().to_string_lossy(),
|
|
83
|
+
"once": args.once,
|
|
84
|
+
});
|
|
85
|
+
if let Some(metadata) = tmux_metadata {
|
|
86
|
+
if let Some(object) = boot_event.as_object_mut() {
|
|
87
|
+
object.insert(
|
|
88
|
+
"tmux_endpoint_source".to_string(),
|
|
89
|
+
serde_json::Value::String(metadata.tmux_endpoint_source.to_string()),
|
|
90
|
+
);
|
|
91
|
+
object.insert(
|
|
92
|
+
"tmux_endpoint_used".to_string(),
|
|
93
|
+
metadata
|
|
94
|
+
.tmux_endpoint_used
|
|
95
|
+
.map(serde_json::Value::String)
|
|
96
|
+
.unwrap_or(serde_json::Value::Null),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
event_log.write("coordinator.boot", boot_event)?;
|
|
67
101
|
let tick_interval = match args.tick_interval_sec {
|
|
68
102
|
Some(v) if v > 0.0 => v,
|
|
69
103
|
_ => resolve_tick_interval(&args.workspace)?,
|
|
@@ -209,6 +209,156 @@ fn coord_over_staged_tmux(
|
|
|
209
209
|
(coord, dir, seen)
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
fn coord_over_runtime_state_tmux_endpoint(
|
|
213
|
+
session_name: &str,
|
|
214
|
+
endpoint: &str,
|
|
215
|
+
steps: Vec<RunnerStep>,
|
|
216
|
+
last: RunnerStep,
|
|
217
|
+
) -> (
|
|
218
|
+
Coordinator,
|
|
219
|
+
std::path::PathBuf,
|
|
220
|
+
std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>>,
|
|
221
|
+
Option<String>,
|
|
222
|
+
&'static str,
|
|
223
|
+
) {
|
|
224
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
225
|
+
static N: AtomicU64 = AtomicU64::new(0);
|
|
226
|
+
let dir = std::env::temp_dir().join(format!(
|
|
227
|
+
"ta-rs-e27-coord-explicit-{}-{}",
|
|
228
|
+
std::process::id(),
|
|
229
|
+
N.fetch_add(1, Ordering::Relaxed)
|
|
230
|
+
));
|
|
231
|
+
std::fs::create_dir_all(crate::model::paths::runtime_dir(&dir)).unwrap();
|
|
232
|
+
let _ = crate::message_store::MessageStore::open(&dir).unwrap();
|
|
233
|
+
let state = serde_json::json!({
|
|
234
|
+
"session_name": session_name,
|
|
235
|
+
"tmux_endpoint": endpoint,
|
|
236
|
+
"tmux_socket": endpoint,
|
|
237
|
+
"agents": {},
|
|
238
|
+
});
|
|
239
|
+
crate::state::persist::save_runtime_state(&dir, &state).unwrap();
|
|
240
|
+
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
241
|
+
let runner = StagedTmuxRunner {
|
|
242
|
+
steps: std::sync::Mutex::new(steps.into_iter().collect()),
|
|
243
|
+
last,
|
|
244
|
+
seen: std::sync::Arc::clone(&seen),
|
|
245
|
+
};
|
|
246
|
+
let selection = crate::tmux_backend::tmux_backend_with_runner_for_runtime_state_or_workspace(
|
|
247
|
+
Box::new(runner),
|
|
248
|
+
&dir,
|
|
249
|
+
Some(&state),
|
|
250
|
+
);
|
|
251
|
+
let endpoint_used = selection.tmux_endpoint_used.clone();
|
|
252
|
+
let endpoint_source = selection.tmux_endpoint_source.as_str();
|
|
253
|
+
let reg: Box<dyn ProviderRegistry> = Box::new(MockRegistry::new(&[], &[]));
|
|
254
|
+
let coord = Coordinator::for_test(
|
|
255
|
+
WorkspacePath::new(dir.clone()),
|
|
256
|
+
reg,
|
|
257
|
+
Box::new(selection.backend),
|
|
258
|
+
None,
|
|
259
|
+
None,
|
|
260
|
+
);
|
|
261
|
+
(coord, dir, seen, endpoint_used, endpoint_source)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#[test]
|
|
265
|
+
fn e27_coordinator_tick_uses_runtime_explicit_endpoint_for_session_gate() {
|
|
266
|
+
let session = "team-e27-explicit-restart";
|
|
267
|
+
let endpoint = "/tmp/e27-explicit-restart-test.sock";
|
|
268
|
+
let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
|
|
269
|
+
session,
|
|
270
|
+
endpoint,
|
|
271
|
+
vec![RunnerStep::Exit(true)],
|
|
272
|
+
RunnerStep::Exit(true),
|
|
273
|
+
);
|
|
274
|
+
assert_eq!(endpoint_used.as_deref(), Some(endpoint));
|
|
275
|
+
assert_eq!(endpoint_source, "state.tmux_endpoint");
|
|
276
|
+
|
|
277
|
+
let report = coord
|
|
278
|
+
.tick()
|
|
279
|
+
.expect("explicit endpoint with present session should tick");
|
|
280
|
+
assert!(
|
|
281
|
+
report.ok,
|
|
282
|
+
"present explicit endpoint session should keep tick ok"
|
|
283
|
+
);
|
|
284
|
+
assert!(
|
|
285
|
+
!report.stop,
|
|
286
|
+
"present explicit endpoint session must not trip the session-missing stop gate"
|
|
287
|
+
);
|
|
288
|
+
let calls = seen.lock().unwrap().clone();
|
|
289
|
+
assert!(
|
|
290
|
+
calls
|
|
291
|
+
.iter()
|
|
292
|
+
.all(|argv| !argv.iter().any(|part| part == "-L")),
|
|
293
|
+
"explicit endpoint coordinator must not fall back to workspace -L socket; got {calls:?}"
|
|
294
|
+
);
|
|
295
|
+
assert_eq!(
|
|
296
|
+
calls.first(),
|
|
297
|
+
Some(&vec![
|
|
298
|
+
"tmux".to_string(),
|
|
299
|
+
"-S".to_string(),
|
|
300
|
+
endpoint.to_string(),
|
|
301
|
+
"has-session".to_string(),
|
|
302
|
+
"-t".to_string(),
|
|
303
|
+
session.to_string(),
|
|
304
|
+
]),
|
|
305
|
+
"session gate must probe the persisted explicit endpoint"
|
|
306
|
+
);
|
|
307
|
+
let events = read_event_log_dir(&dir);
|
|
308
|
+
assert!(
|
|
309
|
+
events
|
|
310
|
+
.iter()
|
|
311
|
+
.all(|event| event.get("event").and_then(|v| v.as_str())
|
|
312
|
+
!= Some("coordinator.session_missing")),
|
|
313
|
+
"present explicit endpoint session must not emit coordinator.session_missing; got {events:?}"
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[test]
|
|
318
|
+
fn e27_coordinator_tick_still_stops_when_explicit_endpoint_session_is_missing() {
|
|
319
|
+
let session = "team-e27-explicit-restart";
|
|
320
|
+
let endpoint = "/tmp/e27-explicit-restart-test.sock";
|
|
321
|
+
let (coord, dir, seen, endpoint_used, endpoint_source) = coord_over_runtime_state_tmux_endpoint(
|
|
322
|
+
session,
|
|
323
|
+
endpoint,
|
|
324
|
+
vec![RunnerStep::Exit(false)],
|
|
325
|
+
RunnerStep::Exit(false),
|
|
326
|
+
);
|
|
327
|
+
assert_eq!(endpoint_used.as_deref(), Some(endpoint));
|
|
328
|
+
assert_eq!(endpoint_source, "state.tmux_endpoint");
|
|
329
|
+
|
|
330
|
+
let report = coord
|
|
331
|
+
.tick()
|
|
332
|
+
.expect("definitive missing session is a typed stop report");
|
|
333
|
+
assert!(!report.ok, "missing explicit endpoint session => ok=false");
|
|
334
|
+
assert!(
|
|
335
|
+
report.stop,
|
|
336
|
+
"genuine missing session on the selected explicit endpoint must still stop"
|
|
337
|
+
);
|
|
338
|
+
assert_eq!(report.reason, Some(TickStopReason::TmuxSessionMissing));
|
|
339
|
+
let calls = seen.lock().unwrap().clone();
|
|
340
|
+
assert_eq!(
|
|
341
|
+
calls.first(),
|
|
342
|
+
Some(&vec![
|
|
343
|
+
"tmux".to_string(),
|
|
344
|
+
"-S".to_string(),
|
|
345
|
+
endpoint.to_string(),
|
|
346
|
+
"has-session".to_string(),
|
|
347
|
+
"-t".to_string(),
|
|
348
|
+
session.to_string(),
|
|
349
|
+
]),
|
|
350
|
+
"negative control must also probe the persisted explicit endpoint"
|
|
351
|
+
);
|
|
352
|
+
let events = read_event_log_dir(&dir);
|
|
353
|
+
assert!(
|
|
354
|
+
events
|
|
355
|
+
.iter()
|
|
356
|
+
.any(|event| event.get("event").and_then(|v| v.as_str())
|
|
357
|
+
== Some("coordinator.session_missing")),
|
|
358
|
+
"genuine explicit endpoint miss must still emit coordinator.session_missing; got {events:?}"
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
212
362
|
// ── 2(a) tick TOLERATES a has-session TIMEOUT as Err (NOT a definitive miss) — LOCK ───────────────
|
|
213
363
|
#[test]
|
|
214
364
|
fn tick_tolerates_has_session_timeout_as_transport_err_not_session_missing() {
|
|
@@ -144,6 +144,7 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
|
|
|
144
144
|
let mut targets = Vec::new();
|
|
145
145
|
for endpoint in state_recorded_tmux_endpoints(state) {
|
|
146
146
|
let backend = tmux_backend_for_endpoint(&endpoint);
|
|
147
|
+
let resolved_endpoint = backend.tmux_endpoint();
|
|
147
148
|
targets.extend(
|
|
148
149
|
backend
|
|
149
150
|
.list_targets()
|
|
@@ -151,7 +152,7 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
|
|
|
151
152
|
.into_iter()
|
|
152
153
|
.map(|info| AttachLeaderTarget {
|
|
153
154
|
info,
|
|
154
|
-
endpoint:
|
|
155
|
+
endpoint: resolved_endpoint.clone(),
|
|
155
156
|
}),
|
|
156
157
|
);
|
|
157
158
|
}
|
|
@@ -168,10 +169,8 @@ fn attach_leader_targets(workspace: &Path, state: &Value) -> Vec<AttachLeaderTar
|
|
|
168
169
|
fn tmux_backend_for_endpoint(endpoint: &str) -> crate::tmux_backend::TmuxBackend {
|
|
169
170
|
if endpoint.is_empty() || endpoint == "default" {
|
|
170
171
|
crate::tmux_backend::TmuxBackend::new()
|
|
171
|
-
} else if Path::new(endpoint).is_absolute() {
|
|
172
|
-
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
173
172
|
} else {
|
|
174
|
-
crate::tmux_backend::TmuxBackend::
|
|
173
|
+
crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
|
|
175
174
|
}
|
|
176
175
|
}
|
|
177
176
|
|
|
@@ -196,6 +196,9 @@ pub fn execute_leader_plan(
|
|
|
196
196
|
let detached = plan.mode == LeaderStartMode::NewTmuxSession
|
|
197
197
|
&& !std::io::stdin().is_terminal()
|
|
198
198
|
&& insert_detach_flag(&mut argv);
|
|
199
|
+
if plan.is_external_leader {
|
|
200
|
+
persist_external_leader_topology_marker(plan, workspace)?;
|
|
201
|
+
}
|
|
199
202
|
let status = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
|
|
200
203
|
let code = status.code();
|
|
201
204
|
if !status.success() {
|
|
@@ -205,9 +208,6 @@ pub fn execute_leader_plan(
|
|
|
205
208
|
.unwrap_or_else(|| "signal".to_string())
|
|
206
209
|
)));
|
|
207
210
|
}
|
|
208
|
-
if plan.is_external_leader {
|
|
209
|
-
persist_external_leader_topology_marker(plan, workspace)?;
|
|
210
|
-
}
|
|
211
211
|
if detached {
|
|
212
212
|
Ok(LeaderLaunchOutcome {
|
|
213
213
|
status: LeaderLaunchStatus::Detached,
|
|
@@ -258,7 +258,7 @@ fn start_argv(
|
|
|
258
258
|
match mode {
|
|
259
259
|
LeaderStartMode::ExecProvider => {
|
|
260
260
|
let mut argv = vec![provider_cmd];
|
|
261
|
-
argv.extend(provider_args
|
|
261
|
+
argv.extend(normalized_provider_args(provider_args));
|
|
262
262
|
Ok(argv)
|
|
263
263
|
}
|
|
264
264
|
LeaderStartMode::ManagedTmuxClient => {
|
|
@@ -289,7 +289,7 @@ fn start_argv(
|
|
|
289
289
|
exports.push(shlex_quote(&format!("PATH={path}")));
|
|
290
290
|
}
|
|
291
291
|
let mut provider_argv = vec![provider_cmd];
|
|
292
|
-
provider_argv.extend(provider_args
|
|
292
|
+
provider_argv.extend(normalized_provider_args(provider_args));
|
|
293
293
|
let shell = format!(
|
|
294
294
|
"cd {} && export {} && exec {}",
|
|
295
295
|
shlex_quote(&resolved_workspace.to_string_lossy()),
|
|
@@ -316,10 +316,17 @@ fn start_argv(
|
|
|
316
316
|
|
|
317
317
|
fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
|
|
318
318
|
let mut argv = vec![provider_command_name(provider).to_string()];
|
|
319
|
-
argv.extend(provider_args
|
|
319
|
+
argv.extend(normalized_provider_args(provider_args));
|
|
320
320
|
argv
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = String> + '_ {
|
|
324
|
+
provider_args
|
|
325
|
+
.iter()
|
|
326
|
+
.skip(usize::from(provider_args.first().is_some_and(|arg| arg == "--")))
|
|
327
|
+
.cloned()
|
|
328
|
+
}
|
|
329
|
+
|
|
323
330
|
fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
|
|
324
331
|
let team = identity.team_id.as_str();
|
|
325
332
|
if team.starts_with("team-") {
|
|
@@ -777,7 +784,12 @@ mod tests {
|
|
|
777
784
|
use std::path::Path;
|
|
778
785
|
use std::sync::Mutex;
|
|
779
786
|
|
|
787
|
+
use crate::leader::{
|
|
788
|
+
LeaderIdentity, LeaderLaunchSocket, LeaderSessionUuidSource, LeaderStartMode,
|
|
789
|
+
LeaderStartPlan,
|
|
790
|
+
};
|
|
780
791
|
use crate::model::enums::PaneLiveness;
|
|
792
|
+
use crate::model::ids::{LeaderSessionUuid, TeamKey};
|
|
781
793
|
use crate::provider::{Provider, COPILOT_READY_MARKER, COPILOT_TRUST_PROMPT_MARKER};
|
|
782
794
|
use crate::transport::{
|
|
783
795
|
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
@@ -786,7 +798,7 @@ mod tests {
|
|
|
786
798
|
TurnVerification, WindowName,
|
|
787
799
|
};
|
|
788
800
|
|
|
789
|
-
use super::handle_exec_provider_startup_prompts;
|
|
801
|
+
use super::{execute_leader_plan, handle_exec_provider_startup_prompts, shlex_quote};
|
|
790
802
|
|
|
791
803
|
struct ScriptedTransport {
|
|
792
804
|
screens: Mutex<Vec<String>>,
|
|
@@ -937,6 +949,57 @@ mod tests {
|
|
|
937
949
|
}
|
|
938
950
|
}
|
|
939
951
|
|
|
952
|
+
#[test]
|
|
953
|
+
fn external_exec_provider_persists_topology_before_provider_exec() {
|
|
954
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
955
|
+
"ta-external-pre-exec-{}",
|
|
956
|
+
std::process::id()
|
|
957
|
+
));
|
|
958
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
959
|
+
let state_path = crate::state::persist::runtime_state_path(&workspace);
|
|
960
|
+
let command = format!(
|
|
961
|
+
"test -f {path} && grep -q is_external_leader {path}",
|
|
962
|
+
path = shlex_quote(&state_path.to_string_lossy())
|
|
963
|
+
);
|
|
964
|
+
let identity = LeaderIdentity {
|
|
965
|
+
leader_session_uuid: LeaderSessionUuid::derive(
|
|
966
|
+
"fp",
|
|
967
|
+
&workspace.to_string_lossy(),
|
|
968
|
+
"tester",
|
|
969
|
+
"current",
|
|
970
|
+
)
|
|
971
|
+
.unwrap(),
|
|
972
|
+
leader_session_uuid_source: LeaderSessionUuidSource::Derived,
|
|
973
|
+
machine_fingerprint: "fp".to_string(),
|
|
974
|
+
workspace_abspath: workspace.clone(),
|
|
975
|
+
os_user: "tester".to_string(),
|
|
976
|
+
team_id: TeamKey::new("current"),
|
|
977
|
+
};
|
|
978
|
+
let plan = LeaderStartPlan {
|
|
979
|
+
mode: LeaderStartMode::ExecProvider,
|
|
980
|
+
provider: Provider::Codex,
|
|
981
|
+
workspace: workspace.clone(),
|
|
982
|
+
socket: LeaderLaunchSocket::Workspace,
|
|
983
|
+
session_name: None,
|
|
984
|
+
argv: vec!["sh".to_string(), "-c".to_string(), command],
|
|
985
|
+
provider_argv: vec!["codex".to_string()],
|
|
986
|
+
leader_window: None,
|
|
987
|
+
is_external_leader: true,
|
|
988
|
+
leader_env: BTreeMap::new(),
|
|
989
|
+
identity: Some(identity),
|
|
990
|
+
detached: false,
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
let outcome = execute_leader_plan(&plan, &workspace)
|
|
994
|
+
.expect("external marker must be present before provider argv runs");
|
|
995
|
+
|
|
996
|
+
assert_eq!(outcome.status, crate::leader::LeaderLaunchStatus::Exited);
|
|
997
|
+
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
998
|
+
assert_eq!(state["is_external_leader"], serde_json::json!(true));
|
|
999
|
+
assert_eq!(state["teams"]["current"]["is_external_leader"], serde_json::json!(true));
|
|
1000
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
940
1003
|
#[test]
|
|
941
1004
|
fn exec_provider_leader_startup_prompt_handler_reuses_copilot_adapter() {
|
|
942
1005
|
let transport = ScriptedTransport::new(vec![
|
|
@@ -108,12 +108,20 @@ use super::*;
|
|
|
108
108
|
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_external_{}", std::process::id()));
|
|
109
109
|
std::fs::create_dir_all(&ws).unwrap();
|
|
110
110
|
|
|
111
|
-
let
|
|
111
|
+
let provider_args = vec!["--".to_string(), "--model".to_string(), "opus".to_string()];
|
|
112
|
+
let plan = leader_start_plan(Provider::Fake, &provider_args, &ws, false, false, None, true).unwrap();
|
|
112
113
|
|
|
113
114
|
assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
|
|
114
115
|
assert!(plan.is_external_leader);
|
|
115
116
|
assert_eq!(plan.leader_window, None);
|
|
116
|
-
assert_eq!(
|
|
117
|
+
assert_eq!(
|
|
118
|
+
plan.argv,
|
|
119
|
+
vec!["fake".to_string(), "--model".to_string(), "opus".to_string()]
|
|
120
|
+
);
|
|
121
|
+
assert_eq!(
|
|
122
|
+
plan.provider_argv,
|
|
123
|
+
vec!["fake".to_string(), "--model".to_string(), "opus".to_string()]
|
|
124
|
+
);
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
#[test]
|
|
@@ -17,6 +17,8 @@ pub fn start_agent(
|
|
|
17
17
|
team: Option<&str>,
|
|
18
18
|
) -> Result<StartAgentOutcome, LifecycleError> {
|
|
19
19
|
let paths = lifecycle_paths(workspace, team)?;
|
|
20
|
+
let transport =
|
|
21
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
20
22
|
start_agent_at_paths(
|
|
21
23
|
&paths.run_workspace,
|
|
22
24
|
&paths.spec_workspace,
|
|
@@ -25,7 +27,7 @@ pub fn start_agent(
|
|
|
25
27
|
open_display,
|
|
26
28
|
allow_fresh,
|
|
27
29
|
team,
|
|
28
|
-
&
|
|
30
|
+
&transport,
|
|
29
31
|
)
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -267,11 +269,15 @@ pub fn stop_agent(
|
|
|
267
269
|
agent_id: &AgentId,
|
|
268
270
|
team: Option<&str>,
|
|
269
271
|
) -> Result<StopAgentReport, LifecycleError> {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
273
|
+
let transport =
|
|
274
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
275
|
+
stop_agent_at_paths(
|
|
276
|
+
&paths.run_workspace,
|
|
277
|
+
&paths.spec_workspace,
|
|
272
278
|
agent_id,
|
|
273
279
|
team,
|
|
274
|
-
&
|
|
280
|
+
&transport,
|
|
275
281
|
)
|
|
276
282
|
}
|
|
277
283
|
|
|
@@ -473,13 +479,22 @@ pub fn reset_agent(
|
|
|
473
479
|
open_display: bool,
|
|
474
480
|
team: Option<&str>,
|
|
475
481
|
) -> Result<ResetAgentOutcome, LifecycleError> {
|
|
476
|
-
|
|
477
|
-
|
|
482
|
+
if !discard_session {
|
|
483
|
+
return Ok(ResetAgentOutcome::Refused {
|
|
484
|
+
reason: ResetRefusal::DiscardSessionRequired,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
488
|
+
let transport =
|
|
489
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
490
|
+
reset_agent_at_paths(
|
|
491
|
+
&paths.run_workspace,
|
|
492
|
+
&paths.spec_workspace,
|
|
478
493
|
agent_id,
|
|
479
494
|
discard_session,
|
|
480
495
|
open_display,
|
|
481
496
|
team,
|
|
482
|
-
&
|
|
497
|
+
&transport,
|
|
483
498
|
)
|
|
484
499
|
}
|
|
485
500
|
|
|
@@ -208,6 +208,38 @@ pub(super) fn start_coordinator_for_workspace(workspace: &Path) -> Result<bool,
|
|
|
208
208
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
pub(super) fn lifecycle_worker_tmux_backend_for_selected_state(
|
|
212
|
+
run_workspace: &Path,
|
|
213
|
+
team: Option<&str>,
|
|
214
|
+
) -> Result<crate::tmux_backend::TmuxBackend, LifecycleError> {
|
|
215
|
+
let (state, refusal) = crate::state::projection::resolve_team_scoped_state(run_workspace, team)
|
|
216
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
217
|
+
if let Some(refusal) = refusal {
|
|
218
|
+
let reason = refusal
|
|
219
|
+
.get("reason")
|
|
220
|
+
.and_then(|v| v.as_str())
|
|
221
|
+
.unwrap_or("team_target_unresolved");
|
|
222
|
+
let detail = refusal
|
|
223
|
+
.get("error")
|
|
224
|
+
.or_else(|| refusal.get("message"))
|
|
225
|
+
.and_then(|v| v.as_str())
|
|
226
|
+
.unwrap_or("");
|
|
227
|
+
return Err(LifecycleError::TeamSelect(format!("{reason}: {detail}")));
|
|
228
|
+
}
|
|
229
|
+
Ok(state
|
|
230
|
+
.as_ref()
|
|
231
|
+
.map(|state| lifecycle_worker_tmux_backend_for_state(run_workspace, state))
|
|
232
|
+
.unwrap_or_else(|| crate::tmux_backend::TmuxBackend::for_workspace(run_workspace)))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
pub(super) fn lifecycle_worker_tmux_backend_for_state(
|
|
236
|
+
run_workspace: &Path,
|
|
237
|
+
state: &serde_json::Value,
|
|
238
|
+
) -> crate::tmux_backend::TmuxBackend {
|
|
239
|
+
crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(run_workspace, Some(state))
|
|
240
|
+
.backend
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
pub(super) fn persist_effective_approval_policy_for_restart(
|
|
212
244
|
agent: &mut serde_json::Map<String, serde_json::Value>,
|
|
213
245
|
safety: &DangerousApproval,
|
|
@@ -22,12 +22,14 @@ pub fn restart_with_session_convergence_deadline(
|
|
|
22
22
|
team: Option<&str>,
|
|
23
23
|
session_converge_deadline_ms: Option<u64>,
|
|
24
24
|
) -> Result<RestartReport, LifecycleError> {
|
|
25
|
-
let
|
|
25
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
26
|
+
let transport =
|
|
27
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
26
28
|
restart_with_transport_with_session_convergence_deadline(
|
|
27
29
|
workspace,
|
|
28
30
|
allow_fresh,
|
|
29
31
|
team,
|
|
30
|
-
&
|
|
32
|
+
&transport,
|
|
31
33
|
session_converge_deadline_ms,
|
|
32
34
|
None,
|
|
33
35
|
)
|
|
@@ -13,13 +13,17 @@ pub fn remove_agent(
|
|
|
13
13
|
force: bool,
|
|
14
14
|
team: Option<&str>,
|
|
15
15
|
) -> Result<RemoveAgentOutcome, LifecycleError> {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
let paths = lifecycle_paths(workspace, team)?;
|
|
17
|
+
let transport =
|
|
18
|
+
lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
|
|
19
|
+
remove_agent_at_paths(
|
|
20
|
+
&paths.run_workspace,
|
|
21
|
+
&paths.spec_workspace,
|
|
18
22
|
agent_id,
|
|
19
23
|
from_spec,
|
|
20
24
|
force,
|
|
21
25
|
team,
|
|
22
|
-
&
|
|
26
|
+
&transport,
|
|
23
27
|
)
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -260,6 +260,228 @@ fn bug_a_stop_agent_team_dir_input_kills_existing_window_real_machine() {
|
|
|
260
260
|
assert!(report.stopped, "existing worker window must be killed, not silently reported absent");
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
struct EnvVarGuard {
|
|
264
|
+
key: &'static str,
|
|
265
|
+
previous: Option<String>,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
impl EnvVarGuard {
|
|
269
|
+
fn set(key: &'static str, value: &str) -> Self {
|
|
270
|
+
let previous = std::env::var(key).ok();
|
|
271
|
+
unsafe {
|
|
272
|
+
std::env::set_var(key, value);
|
|
273
|
+
}
|
|
274
|
+
Self { key, previous }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
impl Drop for EnvVarGuard {
|
|
279
|
+
fn drop(&mut self) {
|
|
280
|
+
unsafe {
|
|
281
|
+
if let Some(value) = self.previous.take() {
|
|
282
|
+
std::env::set_var(self.key, value);
|
|
283
|
+
} else {
|
|
284
|
+
std::env::remove_var(self.key);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
struct TmuxShim {
|
|
291
|
+
log: std::path::PathBuf,
|
|
292
|
+
_path: EnvVarGuard,
|
|
293
|
+
_log: EnvVarGuard,
|
|
294
|
+
_endpoint: EnvVarGuard,
|
|
295
|
+
_session: EnvVarGuard,
|
|
296
|
+
_pane: EnvVarGuard,
|
|
297
|
+
_real_tmux: EnvVarGuard,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fn install_e27_tmux_shim(expected_endpoint: &str, session_name: &str) -> TmuxShim {
|
|
301
|
+
use std::os::unix::fs::PermissionsExt;
|
|
302
|
+
|
|
303
|
+
let root = temp_ws().join("e27_tmux_shim");
|
|
304
|
+
let bin = root.join("bin");
|
|
305
|
+
std::fs::create_dir_all(&bin).unwrap();
|
|
306
|
+
let log = root.join("tmux-argv.log");
|
|
307
|
+
let tmux = bin.join("tmux");
|
|
308
|
+
let real_tmux = std::process::Command::new("sh")
|
|
309
|
+
.arg("-lc")
|
|
310
|
+
.arg("command -v tmux || true")
|
|
311
|
+
.output()
|
|
312
|
+
.ok()
|
|
313
|
+
.and_then(|out| String::from_utf8(out.stdout).ok())
|
|
314
|
+
.map(|s| s.trim().to_string())
|
|
315
|
+
.filter(|s| !s.is_empty())
|
|
316
|
+
.unwrap_or_default();
|
|
317
|
+
std::fs::write(
|
|
318
|
+
&tmux,
|
|
319
|
+
r#"#!/bin/sh
|
|
320
|
+
set -eu
|
|
321
|
+
log="${TEAM_AGENT_E27_TMUX_LOG}"
|
|
322
|
+
expected="${TEAM_AGENT_E27_EXPECTED_ENDPOINT}"
|
|
323
|
+
session="${TEAM_AGENT_E27_SESSION_NAME}"
|
|
324
|
+
pane="${TEAM_AGENT_E27_PANE_ID}"
|
|
325
|
+
track=0
|
|
326
|
+
case "$*" in
|
|
327
|
+
*"$expected"*|*"$session"*|*"$pane"*) track=1 ;;
|
|
328
|
+
esac
|
|
329
|
+
if [ "$track" != 1 ]; then
|
|
330
|
+
if [ -n "${TEAM_AGENT_E27_REAL_TMUX}" ]; then
|
|
331
|
+
exec "${TEAM_AGENT_E27_REAL_TMUX}" "$@"
|
|
332
|
+
fi
|
|
333
|
+
exit 127
|
|
334
|
+
fi
|
|
335
|
+
printf '%s\n' "$*" >> "$log"
|
|
336
|
+
case "$*" in
|
|
337
|
+
*"-S $expected"*) ;;
|
|
338
|
+
*)
|
|
339
|
+
echo "missing expected socket $expected: $*" >&2
|
|
340
|
+
exit 18
|
|
341
|
+
;;
|
|
342
|
+
esac
|
|
343
|
+
case "$*" in
|
|
344
|
+
*"display-message -p -t $pane #{pane_id}"*)
|
|
345
|
+
printf '%s\n' "$pane"
|
|
346
|
+
exit 0
|
|
347
|
+
;;
|
|
348
|
+
*"display-message -p -t $session:alpha #{pane_id}"*)
|
|
349
|
+
printf '%%9288\n'
|
|
350
|
+
exit 0
|
|
351
|
+
;;
|
|
352
|
+
*"list-windows -t $session -F #{window_name}"*)
|
|
353
|
+
exit 0
|
|
354
|
+
;;
|
|
355
|
+
*"has-session -t $session"*)
|
|
356
|
+
exit 0
|
|
357
|
+
;;
|
|
358
|
+
*)
|
|
359
|
+
exit 0
|
|
360
|
+
;;
|
|
361
|
+
esac
|
|
362
|
+
"#,
|
|
363
|
+
)
|
|
364
|
+
.unwrap();
|
|
365
|
+
let mut perms = std::fs::metadata(&tmux).unwrap().permissions();
|
|
366
|
+
perms.set_mode(0o755);
|
|
367
|
+
std::fs::set_permissions(&tmux, perms).unwrap();
|
|
368
|
+
let old_path = std::env::var("PATH").unwrap_or_default();
|
|
369
|
+
TmuxShim {
|
|
370
|
+
log: log.clone(),
|
|
371
|
+
_path: EnvVarGuard::set("PATH", &format!("{}:{old_path}", bin.display())),
|
|
372
|
+
_log: EnvVarGuard::set("TEAM_AGENT_E27_TMUX_LOG", &log.to_string_lossy()),
|
|
373
|
+
_endpoint: EnvVarGuard::set("TEAM_AGENT_E27_EXPECTED_ENDPOINT", expected_endpoint),
|
|
374
|
+
_session: EnvVarGuard::set("TEAM_AGENT_E27_SESSION_NAME", session_name),
|
|
375
|
+
_pane: EnvVarGuard::set("TEAM_AGENT_E27_PANE_ID", "%9277"),
|
|
376
|
+
_real_tmux: EnvVarGuard::set("TEAM_AGENT_E27_REAL_TMUX", &real_tmux),
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
fn seed_e27_attached_socket_state(ws: &std::path::Path, endpoint: &str, session_name: &str) {
|
|
381
|
+
let mut state = crate::state::persist::load_runtime_state(ws).unwrap();
|
|
382
|
+
let obj = state.as_object_mut().unwrap();
|
|
383
|
+
obj.insert("session_name".to_string(), json!(session_name));
|
|
384
|
+
obj.insert("tmux_endpoint".to_string(), json!(endpoint));
|
|
385
|
+
obj.insert("tmux_socket".to_string(), json!(endpoint));
|
|
386
|
+
let alpha = obj
|
|
387
|
+
.get_mut("agents")
|
|
388
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
389
|
+
.and_then(|agents| agents.get_mut("alpha"))
|
|
390
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
391
|
+
.unwrap();
|
|
392
|
+
alpha.insert("status".to_string(), json!("running"));
|
|
393
|
+
alpha.insert("provider".to_string(), json!("fake"));
|
|
394
|
+
alpha.insert("window".to_string(), json!("alpha"));
|
|
395
|
+
alpha.insert("pane_id".to_string(), json!("%9277"));
|
|
396
|
+
alpha.insert("session_id".to_string(), json!("old-session"));
|
|
397
|
+
crate::state::persist::save_runtime_state(ws, &state).unwrap();
|
|
398
|
+
seed_healthy_coordinator(ws);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
fn assert_only_expected_socket_used(log: &std::path::Path, expected_endpoint: &str) {
|
|
402
|
+
let raw = std::fs::read_to_string(log).unwrap();
|
|
403
|
+
assert!(!raw.is_empty(), "tmux shim was not invoked");
|
|
404
|
+
assert!(
|
|
405
|
+
!raw.contains("-L ta-"),
|
|
406
|
+
"lifecycle worker operation must not use workspace-derived -L ta-* socket; argv log:\n{raw}"
|
|
407
|
+
);
|
|
408
|
+
for line in raw.lines() {
|
|
409
|
+
assert!(
|
|
410
|
+
line.contains(&format!("-S {expected_endpoint}")),
|
|
411
|
+
"tmux argv must use state endpoint {expected_endpoint}; got line {line:?}; full log:\n{raw}"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#[test]
|
|
417
|
+
#[serial_test::serial(env)]
|
|
418
|
+
fn e27_stop_agent_uses_attached_explicit_state_socket() {
|
|
419
|
+
let endpoint = "/tmp/loop13-e27-explicit.sock";
|
|
420
|
+
let session_name = "team-e27-explicit-stop";
|
|
421
|
+
let ws = lanea_team_ws("running");
|
|
422
|
+
seed_e27_attached_socket_state(&ws, endpoint, session_name);
|
|
423
|
+
let shim = install_e27_tmux_shim(endpoint, session_name);
|
|
424
|
+
|
|
425
|
+
let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent");
|
|
426
|
+
|
|
427
|
+
assert!(report.stopped, "attached explicit-socket worker should be stopped");
|
|
428
|
+
assert_only_expected_socket_used(&shim.log, endpoint);
|
|
429
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
430
|
+
assert_eq!(
|
|
431
|
+
state.pointer("/agents/alpha/status").and_then(serde_json::Value::as_str),
|
|
432
|
+
Some("stopped")
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#[test]
|
|
437
|
+
#[serial_test::serial(env)]
|
|
438
|
+
fn e27_reset_agent_uses_attached_explicit_state_socket_for_stop_and_spawn() {
|
|
439
|
+
let endpoint = "/tmp/loop13-e27-explicit.sock";
|
|
440
|
+
let session_name = "team-e27-explicit-reset";
|
|
441
|
+
let ws = lanea_team_ws("running");
|
|
442
|
+
seed_e27_attached_socket_state(&ws, endpoint, session_name);
|
|
443
|
+
let shim = install_e27_tmux_shim(endpoint, session_name);
|
|
444
|
+
|
|
445
|
+
let outcome = reset_agent(&ws, &aid("alpha"), true, false, None).expect("reset-agent");
|
|
446
|
+
|
|
447
|
+
assert!(
|
|
448
|
+
matches!(outcome, ResetAgentOutcome::Reset { .. }),
|
|
449
|
+
"reset-agent should complete over attached explicit socket; got {outcome:?}"
|
|
450
|
+
);
|
|
451
|
+
assert_only_expected_socket_used(&shim.log, endpoint);
|
|
452
|
+
let raw = std::fs::read_to_string(&shim.log).unwrap();
|
|
453
|
+
assert!(raw.contains("kill-pane"), "reset must stop the old pane first; argv log:\n{raw}");
|
|
454
|
+
assert!(
|
|
455
|
+
raw.contains("new-window") || raw.contains("new-session"),
|
|
456
|
+
"reset must respawn the worker through the same endpoint; argv log:\n{raw}"
|
|
457
|
+
);
|
|
458
|
+
let state = crate::state::persist::load_runtime_state(&ws).unwrap();
|
|
459
|
+
assert_ne!(
|
|
460
|
+
state.pointer("/agents/alpha/session_id").and_then(serde_json::Value::as_str),
|
|
461
|
+
Some("old-session"),
|
|
462
|
+
"discard-session must not preserve the old session id"
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#[test]
|
|
467
|
+
#[serial_test::serial(env)]
|
|
468
|
+
fn e27_stop_agent_expands_short_state_socket_name() {
|
|
469
|
+
let short = "ta-loop13-e27-short";
|
|
470
|
+
let session_name = "team-e27-short-stop";
|
|
471
|
+
let expected = crate::tmux_backend::socket_path_for_name(short)
|
|
472
|
+
.unwrap()
|
|
473
|
+
.to_string_lossy()
|
|
474
|
+
.to_string();
|
|
475
|
+
let ws = lanea_team_ws("running");
|
|
476
|
+
seed_e27_attached_socket_state(&ws, short, session_name);
|
|
477
|
+
let shim = install_e27_tmux_shim(&expected, session_name);
|
|
478
|
+
|
|
479
|
+
let report = stop_agent(&ws, &aid("alpha"), None).expect("stop-agent short endpoint");
|
|
480
|
+
|
|
481
|
+
assert!(report.stopped, "short endpoint worker should be stopped");
|
|
482
|
+
assert_only_expected_socket_used(&shim.log, &expected);
|
|
483
|
+
}
|
|
484
|
+
|
|
263
485
|
// ═════════════════════════════════════════════════════════════════════════
|
|
264
486
|
// WAVE-2 · LANE A v2 — DEEPENED byte-parity REDs (stop / reset / remove / fork).
|
|
265
487
|
//
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
9
9
|
|
|
10
10
|
use std::collections::{BTreeMap, VecDeque};
|
|
11
|
+
use std::os::unix::net::UnixListener;
|
|
11
12
|
use std::path::Path;
|
|
12
13
|
use std::sync::{Arc, Mutex};
|
|
13
14
|
|
|
@@ -251,8 +252,20 @@
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
#[test]
|
|
255
|
+
#[serial_test::serial(env)]
|
|
254
256
|
fn leader_receiver_short_endpoint_must_not_reconstruct_tmux_l_socket() {
|
|
255
257
|
let endpoint = "dl9aa40c88";
|
|
258
|
+
let uid = unsafe { libc::geteuid() };
|
|
259
|
+
let tmp = std::env::temp_dir().join(format!(
|
|
260
|
+
"ta-tmux-short-endpoint-{}",
|
|
261
|
+
std::process::id()
|
|
262
|
+
));
|
|
263
|
+
let root = tmp.join(format!("tmux-{uid}"));
|
|
264
|
+
std::fs::create_dir_all(&root).unwrap();
|
|
265
|
+
let socket_path = root.join(endpoint);
|
|
266
|
+
let _listener = UnixListener::bind(&socket_path).unwrap();
|
|
267
|
+
let socket_path = socket_path.canonicalize().unwrap();
|
|
268
|
+
let _env = EnvGuard::apply(&[("TMPDIR", Some(tmp.to_str().unwrap()))]);
|
|
256
269
|
let (be, rec) = {
|
|
257
270
|
let recorded = Arc::new(Mutex::new(Vec::new()));
|
|
258
271
|
let runner = MockCommandRunner {
|
|
@@ -275,6 +288,14 @@
|
|
|
275
288
|
"non-canonical leader endpoints must be rejected or left unbound, never reconstructed as \
|
|
276
289
|
tmux -L <short> under the coordinator socket root; calls={calls:?}"
|
|
277
290
|
);
|
|
291
|
+
assert!(
|
|
292
|
+
calls.iter().all(|call| call.starts_with(&[
|
|
293
|
+
"tmux".to_string(),
|
|
294
|
+
"-S".to_string(),
|
|
295
|
+
socket_path.to_string_lossy().to_string()
|
|
296
|
+
])),
|
|
297
|
+
"short leader endpoints must resolve to the existing physical socket path with -S; calls={calls:?}"
|
|
298
|
+
);
|
|
278
299
|
}
|
|
279
300
|
|
|
280
301
|
// ── 1. has_session: exit 0 -> true, exit 1 -> false; argv = `tmux has-session -t <s>` ──────────
|
|
@@ -188,6 +188,29 @@ impl TmuxSocketEndpoint {
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
192
|
+
pub(crate) enum RuntimeTmuxEndpointSource {
|
|
193
|
+
StateTmuxEndpoint,
|
|
194
|
+
StateTmuxSocket,
|
|
195
|
+
WorkspaceFallback,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
impl RuntimeTmuxEndpointSource {
|
|
199
|
+
pub(crate) fn as_str(self) -> &'static str {
|
|
200
|
+
match self {
|
|
201
|
+
Self::StateTmuxEndpoint => "state.tmux_endpoint",
|
|
202
|
+
Self::StateTmuxSocket => "state.tmux_socket",
|
|
203
|
+
Self::WorkspaceFallback => "workspace_fallback",
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub(crate) struct RuntimeTmuxBackendSelection {
|
|
209
|
+
pub(crate) backend: TmuxBackend,
|
|
210
|
+
pub(crate) tmux_endpoint_used: Option<String>,
|
|
211
|
+
pub(crate) tmux_endpoint_source: RuntimeTmuxEndpointSource,
|
|
212
|
+
}
|
|
213
|
+
|
|
191
214
|
impl TmuxBackend {
|
|
192
215
|
/// Backend bound to the real `tmux` subprocess on the SHARED default socket (no `-L`).
|
|
193
216
|
/// Non-team callers + existing argv/unit tests stay unaffected.
|
|
@@ -233,6 +256,12 @@ impl TmuxBackend {
|
|
|
233
256
|
socket: Some(TmuxSocketEndpoint::Path(endpoint.to_string())),
|
|
234
257
|
event_workspace: None,
|
|
235
258
|
}
|
|
259
|
+
} else if let Some(path) = socket_path_for_name(endpoint) {
|
|
260
|
+
Self {
|
|
261
|
+
runner: Box::new(RealCommandRunner),
|
|
262
|
+
socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
|
|
263
|
+
event_workspace: None,
|
|
264
|
+
}
|
|
236
265
|
} else {
|
|
237
266
|
Self::new()
|
|
238
267
|
}
|
|
@@ -275,6 +304,12 @@ impl TmuxBackend {
|
|
|
275
304
|
socket: None,
|
|
276
305
|
event_workspace: None,
|
|
277
306
|
}
|
|
307
|
+
} else if let Some(path) = socket_path_for_name(endpoint) {
|
|
308
|
+
Self {
|
|
309
|
+
runner,
|
|
310
|
+
socket: Some(TmuxSocketEndpoint::Path(path.to_string_lossy().into_owned())),
|
|
311
|
+
event_workspace: None,
|
|
312
|
+
}
|
|
278
313
|
} else {
|
|
279
314
|
Self {
|
|
280
315
|
runner,
|
|
@@ -326,6 +361,70 @@ impl TmuxBackend {
|
|
|
326
361
|
}
|
|
327
362
|
}
|
|
328
363
|
|
|
364
|
+
pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
|
|
365
|
+
workspace: &Path,
|
|
366
|
+
state: Option<&serde_json::Value>,
|
|
367
|
+
) -> RuntimeTmuxBackendSelection {
|
|
368
|
+
let (backend, source) =
|
|
369
|
+
if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
|
|
370
|
+
(TmuxBackend::for_tmux_endpoint(endpoint), source)
|
|
371
|
+
} else {
|
|
372
|
+
(
|
|
373
|
+
TmuxBackend::for_workspace(workspace),
|
|
374
|
+
RuntimeTmuxEndpointSource::WorkspaceFallback,
|
|
375
|
+
)
|
|
376
|
+
};
|
|
377
|
+
RuntimeTmuxBackendSelection {
|
|
378
|
+
tmux_endpoint_used: backend.tmux_endpoint(),
|
|
379
|
+
backend,
|
|
380
|
+
tmux_endpoint_source: source,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[cfg(test)]
|
|
385
|
+
pub(crate) fn tmux_backend_with_runner_for_runtime_state_or_workspace(
|
|
386
|
+
runner: Box<dyn CommandRunner>,
|
|
387
|
+
workspace: &Path,
|
|
388
|
+
state: Option<&serde_json::Value>,
|
|
389
|
+
) -> RuntimeTmuxBackendSelection {
|
|
390
|
+
let (backend, source) =
|
|
391
|
+
if let Some((endpoint, source)) = runtime_tmux_endpoint_from_state(state) {
|
|
392
|
+
(
|
|
393
|
+
TmuxBackend::with_runner_for_tmux_endpoint(runner, endpoint),
|
|
394
|
+
source,
|
|
395
|
+
)
|
|
396
|
+
} else {
|
|
397
|
+
(
|
|
398
|
+
TmuxBackend::with_runner_for_workspace(runner, workspace),
|
|
399
|
+
RuntimeTmuxEndpointSource::WorkspaceFallback,
|
|
400
|
+
)
|
|
401
|
+
};
|
|
402
|
+
RuntimeTmuxBackendSelection {
|
|
403
|
+
tmux_endpoint_used: backend.tmux_endpoint(),
|
|
404
|
+
backend,
|
|
405
|
+
tmux_endpoint_source: source,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
fn runtime_tmux_endpoint_from_state(
|
|
410
|
+
state: Option<&serde_json::Value>,
|
|
411
|
+
) -> Option<(&str, RuntimeTmuxEndpointSource)> {
|
|
412
|
+
state.and_then(|state| {
|
|
413
|
+
state
|
|
414
|
+
.get("tmux_endpoint")
|
|
415
|
+
.and_then(|v| v.as_str())
|
|
416
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
417
|
+
.map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxEndpoint))
|
|
418
|
+
.or_else(|| {
|
|
419
|
+
state
|
|
420
|
+
.get("tmux_socket")
|
|
421
|
+
.and_then(|v| v.as_str())
|
|
422
|
+
.filter(|endpoint| !endpoint.is_empty())
|
|
423
|
+
.map(|endpoint| (endpoint, RuntimeTmuxEndpointSource::StateTmuxSocket))
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
329
428
|
/// CP-1 socket name: SHORT + DETERMINISTIC per canonical workspace path. `ta-` + 12 hex chars of a
|
|
330
429
|
/// stable FNV-1a hash over the canonicalized path. AF_UNIX `sun_path` is ~104 chars and the socket
|
|
331
430
|
/// lives at `/tmp/tmux-<uid>/<name>`, so we must NOT use the (~88-char) session name. §10: a
|
|
@@ -340,13 +439,7 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
|
|
|
340
439
|
}
|
|
341
440
|
|
|
342
441
|
pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
343
|
-
|
|
344
|
-
return Some(existing);
|
|
345
|
-
}
|
|
346
|
-
let uid = unsafe { libc::geteuid() };
|
|
347
|
-
let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
|
|
348
|
-
let default_root = default_root.canonicalize().unwrap_or(default_root);
|
|
349
|
-
Some(default_root.join(socket_name_for_workspace(workspace)))
|
|
442
|
+
socket_path_for_name(&socket_name_for_workspace(workspace))
|
|
350
443
|
}
|
|
351
444
|
|
|
352
445
|
pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
|
|
@@ -354,11 +447,27 @@ pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
|
|
|
354
447
|
}
|
|
355
448
|
|
|
356
449
|
fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
|
|
357
|
-
|
|
450
|
+
existing_socket_path_for_name(&socket_name_for_workspace(workspace))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
pub(crate) fn socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
|
|
454
|
+
if socket_name.is_empty() || socket_name == "default" || Path::new(socket_name).is_absolute() {
|
|
455
|
+
return None;
|
|
456
|
+
}
|
|
457
|
+
if let Some(existing) = existing_socket_path_for_name(socket_name) {
|
|
458
|
+
return Some(existing);
|
|
459
|
+
}
|
|
460
|
+
let uid = unsafe { libc::geteuid() };
|
|
461
|
+
let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
|
|
462
|
+
let default_root = default_root.canonicalize().unwrap_or(default_root);
|
|
463
|
+
Some(default_root.join(socket_name))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
fn existing_socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
|
|
358
467
|
let roots = tmux_socket_roots();
|
|
359
468
|
for root in &roots {
|
|
360
469
|
let root = root.canonicalize().unwrap_or_else(|_| root.clone());
|
|
361
|
-
let candidate = root.join(
|
|
470
|
+
let candidate = root.join(socket_name);
|
|
362
471
|
if candidate.exists() {
|
|
363
472
|
return Some(candidate.canonicalize().unwrap_or(candidate));
|
|
364
473
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@team-agent/installer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.20",
|
|
4
4
|
"description": "npx installer for Team Agent",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"team-agent-installer": "npm/install.mjs"
|
|
21
21
|
},
|
|
22
22
|
"optionalDependencies": {
|
|
23
|
-
"@team-agent/cli-darwin-arm64": "0.3.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
23
|
+
"@team-agent/cli-darwin-arm64": "0.3.20",
|
|
24
|
+
"@team-agent/cli-darwin-x64": "0.3.20",
|
|
25
|
+
"@team-agent/cli-linux-x64": "0.3.20"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"postinstall": "node npm/bincheck.mjs",
|