@team-agent/installer 0.3.15 → 0.3.17
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/leader.rs +2 -0
- package/crates/team-agent/src/cli/mod.rs +199 -31
- package/crates/team-agent/src/cli/status.rs +5 -1
- package/crates/team-agent/src/cli/status_port.rs +24 -0
- package/crates/team-agent/src/cli/tests/base.rs +31 -3
- package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +127 -1
- package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
- package/crates/team-agent/src/cli/types.rs +2 -0
- package/crates/team-agent/src/leader/lease.rs +93 -11
- package/crates/team-agent/src/leader/start.rs +310 -8
- package/crates/team-agent/src/leader/tests/basics.rs +1 -0
- package/crates/team-agent/src/leader/tests/identity.rs +121 -25
- package/crates/team-agent/src/leader/tests/lease_api.rs +2 -2
- package/crates/team-agent/src/leader/types.rs +9 -0
- package/crates/team-agent/src/lifecycle/launch.rs +72 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +63 -0
- package/package.json +4 -4
|
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
|
|
7
7
|
|
|
8
8
|
use crate::provider::{get_adapter, Provider};
|
|
9
9
|
use crate::tmux_backend::TmuxBackend;
|
|
10
|
-
use crate::transport::{PaneId, SessionName, Target, Transport};
|
|
10
|
+
use crate::transport::{PaneId, SessionName, SpawnResult, Target, Transport, WindowName};
|
|
11
11
|
|
|
12
12
|
use super::helpers::{
|
|
13
13
|
provider_wire, resolve_workspace_for_hash, sanitize_session_folder, sha1_hex_prefix,
|
|
@@ -29,6 +29,7 @@ pub fn leader_start_plan(
|
|
|
29
29
|
attach_existing: bool,
|
|
30
30
|
confirm_attach: bool,
|
|
31
31
|
attach_session: Option<&SessionName>,
|
|
32
|
+
external_leader: bool,
|
|
32
33
|
) -> Result<LeaderStartPlan, LeaderError> {
|
|
33
34
|
if attach_session.is_some() && !confirm_attach {
|
|
34
35
|
return Err(LeaderError::Start(
|
|
@@ -49,15 +50,24 @@ pub fn leader_start_plan(
|
|
|
49
50
|
command_name
|
|
50
51
|
)));
|
|
51
52
|
}
|
|
52
|
-
let
|
|
53
|
-
let
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
let state = crate::state::persist::load_runtime_state(workspace).ok();
|
|
54
|
+
let identity = leader_identity_context(workspace, None, state.as_ref())?;
|
|
55
|
+
let external_path = external_leader || attach_existing || attach_session.is_some();
|
|
56
|
+
let session_name = if external_path {
|
|
57
|
+
attach_session
|
|
58
|
+
.cloned()
|
|
59
|
+
.or_else(|| Some(leader_session_name(provider, workspace)))
|
|
60
|
+
} else {
|
|
61
|
+
Some(managed_team_session_name(&identity))
|
|
62
|
+
};
|
|
56
63
|
let in_tmux = std::env::var_os("TMUX").is_some();
|
|
57
|
-
if !in_tmux {
|
|
64
|
+
if !in_tmux || !external_path {
|
|
58
65
|
ensure_tmux_installed()?;
|
|
59
66
|
}
|
|
60
|
-
|
|
67
|
+
if !external_path {
|
|
68
|
+
validate_managed_tmux_context(workspace)?;
|
|
69
|
+
}
|
|
70
|
+
let existing_session = if external_path && !in_tmux && !attach_existing && attach_session.is_none() {
|
|
61
71
|
match session_name.as_ref() {
|
|
62
72
|
Some(session) => tmux_session_exists(workspace, session)?,
|
|
63
73
|
None => false,
|
|
@@ -65,7 +75,9 @@ pub fn leader_start_plan(
|
|
|
65
75
|
} else {
|
|
66
76
|
false
|
|
67
77
|
};
|
|
68
|
-
let mode = if
|
|
78
|
+
let mode = if !external_path {
|
|
79
|
+
LeaderStartMode::ManagedTmuxClient
|
|
80
|
+
} else if in_tmux {
|
|
69
81
|
LeaderStartMode::ExecProvider
|
|
70
82
|
} else if attach_existing || attach_session.is_some() || existing_session {
|
|
71
83
|
LeaderStartMode::AttachExisting
|
|
@@ -86,6 +98,7 @@ pub fn leader_start_plan(
|
|
|
86
98
|
} else {
|
|
87
99
|
leader_env.clone()
|
|
88
100
|
};
|
|
101
|
+
let provider_argv = provider_command_argv(provider, provider_args);
|
|
89
102
|
Ok(LeaderStartPlan {
|
|
90
103
|
mode,
|
|
91
104
|
provider,
|
|
@@ -93,6 +106,10 @@ pub fn leader_start_plan(
|
|
|
93
106
|
socket: LeaderLaunchSocket::Workspace,
|
|
94
107
|
session_name,
|
|
95
108
|
argv,
|
|
109
|
+
provider_argv,
|
|
110
|
+
leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
|
|
111
|
+
.then(|| WindowName::new("leader")),
|
|
112
|
+
is_external_leader: external_path,
|
|
96
113
|
leader_env: plan_env,
|
|
97
114
|
identity: Some(identity),
|
|
98
115
|
detached: false,
|
|
@@ -142,6 +159,7 @@ pub fn start_leader(
|
|
|
142
159
|
attach_existing: bool,
|
|
143
160
|
confirm_attach: bool,
|
|
144
161
|
attach_session: Option<&SessionName>,
|
|
162
|
+
external_leader: bool,
|
|
145
163
|
) -> Result<(), LeaderError> {
|
|
146
164
|
let plan = leader_start_plan(
|
|
147
165
|
provider,
|
|
@@ -150,6 +168,7 @@ pub fn start_leader(
|
|
|
150
168
|
attach_existing,
|
|
151
169
|
confirm_attach,
|
|
152
170
|
attach_session,
|
|
171
|
+
external_leader,
|
|
153
172
|
)?;
|
|
154
173
|
crate::event_log::EventLog::new(workspace).write(
|
|
155
174
|
super::LeaderEvent::LeaderStart.name(),
|
|
@@ -170,6 +189,9 @@ pub fn execute_leader_plan(
|
|
|
170
189
|
plan: &LeaderStartPlan,
|
|
171
190
|
workspace: &Path,
|
|
172
191
|
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
192
|
+
if plan.mode == LeaderStartMode::ManagedTmuxClient {
|
|
193
|
+
return execute_managed_leader_plan(plan, workspace);
|
|
194
|
+
}
|
|
173
195
|
let mut argv = plan.argv.clone();
|
|
174
196
|
let detached = plan.mode == LeaderStartMode::NewTmuxSession
|
|
175
197
|
&& !std::io::stdin().is_terminal()
|
|
@@ -183,6 +205,9 @@ pub fn execute_leader_plan(
|
|
|
183
205
|
.unwrap_or_else(|| "signal".to_string())
|
|
184
206
|
)));
|
|
185
207
|
}
|
|
208
|
+
if plan.is_external_leader {
|
|
209
|
+
persist_external_leader_topology_marker(plan, workspace)?;
|
|
210
|
+
}
|
|
186
211
|
if detached {
|
|
187
212
|
Ok(LeaderLaunchOutcome {
|
|
188
213
|
status: LeaderLaunchStatus::Detached,
|
|
@@ -236,6 +261,12 @@ fn start_argv(
|
|
|
236
261
|
argv.extend(provider_args.iter().cloned());
|
|
237
262
|
Ok(argv)
|
|
238
263
|
}
|
|
264
|
+
LeaderStartMode::ManagedTmuxClient => {
|
|
265
|
+
let Some(session) = session_name else {
|
|
266
|
+
return Err(LeaderError::Start("managed leader session missing".to_string()));
|
|
267
|
+
};
|
|
268
|
+
managed_client_argv(workspace, session)
|
|
269
|
+
}
|
|
239
270
|
LeaderStartMode::AttachExisting => {
|
|
240
271
|
let Some(session) = session_name else {
|
|
241
272
|
return Err(LeaderError::Start("attach session missing".to_string()));
|
|
@@ -283,6 +314,259 @@ fn start_argv(
|
|
|
283
314
|
}
|
|
284
315
|
}
|
|
285
316
|
|
|
317
|
+
fn provider_command_argv(provider: Provider, provider_args: &[String]) -> Vec<String> {
|
|
318
|
+
let mut argv = vec![provider_command_name(provider).to_string()];
|
|
319
|
+
argv.extend(provider_args.iter().cloned());
|
|
320
|
+
argv
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
fn managed_team_session_name(identity: &LeaderIdentity) -> SessionName {
|
|
324
|
+
let team = identity.team_id.as_str();
|
|
325
|
+
if team.starts_with("team-") {
|
|
326
|
+
SessionName::new(team.to_string())
|
|
327
|
+
} else {
|
|
328
|
+
SessionName::new(format!("team-{team}"))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
fn managed_client_argv(workspace: &Path, session: &SessionName) -> Result<Vec<String>, LeaderError> {
|
|
333
|
+
let target = format!("{}:leader", session.as_str());
|
|
334
|
+
let argv = if std::env::var_os("TMUX").is_some() {
|
|
335
|
+
vec![
|
|
336
|
+
"tmux".to_string(),
|
|
337
|
+
"switch-client".to_string(),
|
|
338
|
+
"-t".to_string(),
|
|
339
|
+
target,
|
|
340
|
+
]
|
|
341
|
+
} else {
|
|
342
|
+
vec![
|
|
343
|
+
"tmux".to_string(),
|
|
344
|
+
"attach-session".to_string(),
|
|
345
|
+
"-t".to_string(),
|
|
346
|
+
target,
|
|
347
|
+
]
|
|
348
|
+
};
|
|
349
|
+
Ok(TmuxBackend::argv_for_workspace(workspace, &argv))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
fn validate_managed_tmux_context(workspace: &Path) -> Result<(), LeaderError> {
|
|
353
|
+
let Some(current_endpoint) = crate::tmux_backend::socket_name_from_tmux_env() else {
|
|
354
|
+
return Ok(());
|
|
355
|
+
};
|
|
356
|
+
let expected = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
357
|
+
if tmux_endpoint_matches_socket_name(¤t_endpoint, &expected) {
|
|
358
|
+
return Ok(());
|
|
359
|
+
}
|
|
360
|
+
Err(LeaderError::Start(format!(
|
|
361
|
+
"Error: managed leader launch refused inside a different tmux server\nReason: current TMUX socket is {current_endpoint}, but this team uses {expected}\nAction: detach and run from a normal shell, or use --external-leader for the external leader topology"
|
|
362
|
+
)))
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
fn tmux_endpoint_matches_socket_name(endpoint: &str, socket_name: &str) -> bool {
|
|
366
|
+
endpoint == socket_name
|
|
367
|
+
|| Path::new(endpoint)
|
|
368
|
+
.file_name()
|
|
369
|
+
.and_then(|name| name.to_str())
|
|
370
|
+
== Some(socket_name)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
fn execute_managed_leader_plan(
|
|
374
|
+
plan: &LeaderStartPlan,
|
|
375
|
+
workspace: &Path,
|
|
376
|
+
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
377
|
+
let Some(session) = plan.session_name.as_ref() else {
|
|
378
|
+
return Err(LeaderError::Start("managed leader session missing".to_string()));
|
|
379
|
+
};
|
|
380
|
+
let Some(window) = plan.leader_window.as_ref() else {
|
|
381
|
+
return Err(LeaderError::Start("managed leader window missing".to_string()));
|
|
382
|
+
};
|
|
383
|
+
let transport = TmuxBackend::for_workspace(workspace);
|
|
384
|
+
let spawned = ensure_managed_leader_pane(&transport, session, window, plan, workspace)?;
|
|
385
|
+
persist_managed_leader_binding(plan, workspace, &spawned)?;
|
|
386
|
+
spawn_managed_provider_startup_prompt_handler(
|
|
387
|
+
plan.provider,
|
|
388
|
+
workspace.to_path_buf(),
|
|
389
|
+
spawned.pane_id.as_str().to_string(),
|
|
390
|
+
);
|
|
391
|
+
let status = run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace)?;
|
|
392
|
+
let code = status.code();
|
|
393
|
+
if !status.success() {
|
|
394
|
+
return Err(LeaderError::Start(format!(
|
|
395
|
+
"leader launcher exited with status {}",
|
|
396
|
+
code.map(|c| c.to_string())
|
|
397
|
+
.unwrap_or_else(|| "signal".to_string())
|
|
398
|
+
)));
|
|
399
|
+
}
|
|
400
|
+
Ok(LeaderLaunchOutcome {
|
|
401
|
+
status: LeaderLaunchStatus::Exited,
|
|
402
|
+
exit_code: code,
|
|
403
|
+
session_name: plan.session_name.clone(),
|
|
404
|
+
reason: None,
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
fn ensure_managed_leader_pane(
|
|
409
|
+
transport: &TmuxBackend,
|
|
410
|
+
session: &SessionName,
|
|
411
|
+
window: &WindowName,
|
|
412
|
+
plan: &LeaderStartPlan,
|
|
413
|
+
workspace: &Path,
|
|
414
|
+
) -> Result<SpawnResult, LeaderError> {
|
|
415
|
+
if transport.has_session(session).unwrap_or(false) {
|
|
416
|
+
if transport
|
|
417
|
+
.list_windows(session)
|
|
418
|
+
.unwrap_or_default()
|
|
419
|
+
.iter()
|
|
420
|
+
.any(|existing| existing.as_str() == window.as_str())
|
|
421
|
+
{
|
|
422
|
+
if let Some(existing) = transport
|
|
423
|
+
.list_targets()
|
|
424
|
+
.unwrap_or_default()
|
|
425
|
+
.into_iter()
|
|
426
|
+
.find(|pane| {
|
|
427
|
+
pane.session.as_str() == session.as_str()
|
|
428
|
+
&& pane.window_name.as_ref().map(WindowName::as_str)
|
|
429
|
+
== Some(window.as_str())
|
|
430
|
+
})
|
|
431
|
+
{
|
|
432
|
+
return Ok(SpawnResult {
|
|
433
|
+
pane_id: existing.pane_id,
|
|
434
|
+
session: session.clone(),
|
|
435
|
+
window: window.clone(),
|
|
436
|
+
child_pid: existing.pane_pid,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
transport
|
|
441
|
+
.spawn_into(session, window, &plan.provider_argv, workspace, &plan.leader_env)
|
|
442
|
+
.map_err(|error| LeaderError::Start(error.to_string()))
|
|
443
|
+
} else {
|
|
444
|
+
transport
|
|
445
|
+
.spawn_first(session, window, &plan.provider_argv, workspace, &plan.leader_env)
|
|
446
|
+
.map_err(|error| LeaderError::Start(error.to_string()))
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fn persist_managed_leader_binding(
|
|
451
|
+
plan: &LeaderStartPlan,
|
|
452
|
+
workspace: &Path,
|
|
453
|
+
spawned: &SpawnResult,
|
|
454
|
+
) -> Result<(), LeaderError> {
|
|
455
|
+
let identity = plan
|
|
456
|
+
.identity
|
|
457
|
+
.as_ref()
|
|
458
|
+
.ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
|
|
459
|
+
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
460
|
+
.unwrap_or_else(|_| serde_json::json!({}));
|
|
461
|
+
let owner_epoch = state
|
|
462
|
+
.get("owner_epoch")
|
|
463
|
+
.and_then(serde_json::Value::as_u64)
|
|
464
|
+
.or_else(|| {
|
|
465
|
+
state
|
|
466
|
+
.get("team_owner")
|
|
467
|
+
.and_then(|owner| owner.get("owner_epoch"))
|
|
468
|
+
.and_then(serde_json::Value::as_u64)
|
|
469
|
+
})
|
|
470
|
+
.unwrap_or(0)
|
|
471
|
+
.saturating_add(1);
|
|
472
|
+
let now = chrono::Utc::now().to_rfc3339();
|
|
473
|
+
let socket = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
474
|
+
let provider = serde_json::to_value(plan.provider)?;
|
|
475
|
+
let session = spawned.session.as_str().to_string();
|
|
476
|
+
let window = spawned.window.as_str().to_string();
|
|
477
|
+
let pane = spawned.pane_id.as_str().to_string();
|
|
478
|
+
let receiver = serde_json::json!({
|
|
479
|
+
"mode": "direct_tmux",
|
|
480
|
+
"status": "attached",
|
|
481
|
+
"provider": provider.clone(),
|
|
482
|
+
"pane_id": pane,
|
|
483
|
+
"pane": pane,
|
|
484
|
+
"session_name": session,
|
|
485
|
+
"window_name": window,
|
|
486
|
+
"tmux_socket": socket,
|
|
487
|
+
"leader_session_uuid": identity.leader_session_uuid,
|
|
488
|
+
"owner_epoch": owner_epoch,
|
|
489
|
+
"attached_at": now,
|
|
490
|
+
"discovery": "managed_launcher",
|
|
491
|
+
});
|
|
492
|
+
let owner = serde_json::json!({
|
|
493
|
+
"pane_id": pane,
|
|
494
|
+
"provider": provider.clone(),
|
|
495
|
+
"machine_fingerprint": identity.machine_fingerprint,
|
|
496
|
+
"leader_session_uuid": identity.leader_session_uuid,
|
|
497
|
+
"owner_epoch": owner_epoch,
|
|
498
|
+
"claimed_at": now,
|
|
499
|
+
"claimed_via": "claim-leader",
|
|
500
|
+
"os_user": identity.os_user,
|
|
501
|
+
});
|
|
502
|
+
if let Some(obj) = state.as_object_mut() {
|
|
503
|
+
obj.insert("session_name".to_string(), serde_json::json!(session));
|
|
504
|
+
obj.insert(
|
|
505
|
+
"active_team_key".to_string(),
|
|
506
|
+
serde_json::json!(identity.team_id.as_str()),
|
|
507
|
+
);
|
|
508
|
+
obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
509
|
+
obj.insert("is_external_leader".to_string(), serde_json::json!(false));
|
|
510
|
+
obj.insert(
|
|
511
|
+
"leader_client".to_string(),
|
|
512
|
+
serde_json::json!({
|
|
513
|
+
"diagnostic_only": true,
|
|
514
|
+
"attach_mode": if std::env::var_os("TMUX").is_some() { "switch-client" } else { "attach-session" },
|
|
515
|
+
"tmux": std::env::var("TMUX").ok(),
|
|
516
|
+
}),
|
|
517
|
+
);
|
|
518
|
+
obj.insert("leader_receiver".to_string(), receiver);
|
|
519
|
+
obj.insert("team_owner".to_string(), owner);
|
|
520
|
+
obj.insert("owner_epoch".to_string(), serde_json::json!(owner_epoch));
|
|
521
|
+
}
|
|
522
|
+
let entry = crate::state::projection::compact_team_state(&state);
|
|
523
|
+
if let Some(obj) = state.as_object_mut() {
|
|
524
|
+
let teams = obj
|
|
525
|
+
.entry("teams".to_string())
|
|
526
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
527
|
+
if let Some(teams) = teams.as_object_mut() {
|
|
528
|
+
teams.insert(identity.team_id.as_str().to_string(), entry);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
crate::state::persist::save_runtime_state(workspace, &state)?;
|
|
532
|
+
Ok(())
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fn persist_external_leader_topology_marker(
|
|
536
|
+
plan: &LeaderStartPlan,
|
|
537
|
+
workspace: &Path,
|
|
538
|
+
) -> Result<(), LeaderError> {
|
|
539
|
+
let identity = plan
|
|
540
|
+
.identity
|
|
541
|
+
.as_ref()
|
|
542
|
+
.ok_or_else(|| LeaderError::Start("external leader identity missing".to_string()))?;
|
|
543
|
+
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
544
|
+
.unwrap_or_else(|_| serde_json::json!({}));
|
|
545
|
+
if let Some(obj) = state.as_object_mut() {
|
|
546
|
+
obj.entry("workspace".to_string()).or_insert_with(|| {
|
|
547
|
+
serde_json::json!(resolve_workspace_for_hash(workspace).to_string_lossy().to_string())
|
|
548
|
+
});
|
|
549
|
+
obj.entry("active_team_key".to_string())
|
|
550
|
+
.or_insert_with(|| serde_json::json!(identity.team_id.as_str()));
|
|
551
|
+
if let Some(session) = plan.session_name.as_ref() {
|
|
552
|
+
obj.entry("session_name".to_string())
|
|
553
|
+
.or_insert_with(|| serde_json::json!(session.as_str()));
|
|
554
|
+
}
|
|
555
|
+
obj.insert("is_external_leader".to_string(), serde_json::json!(true));
|
|
556
|
+
}
|
|
557
|
+
let entry = crate::state::projection::compact_team_state(&state);
|
|
558
|
+
if let Some(obj) = state.as_object_mut() {
|
|
559
|
+
let teams = obj
|
|
560
|
+
.entry("teams".to_string())
|
|
561
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
562
|
+
if let Some(teams) = teams.as_object_mut() {
|
|
563
|
+
teams.insert(identity.team_id.as_str().to_string(), entry);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
crate::state::persist::save_runtime_state(workspace, &state)?;
|
|
567
|
+
Ok(())
|
|
568
|
+
}
|
|
569
|
+
|
|
286
570
|
fn insert_detach_flag(argv: &mut Vec<String>) -> bool {
|
|
287
571
|
if argv.iter().any(|arg| arg == "-d") {
|
|
288
572
|
return false;
|
|
@@ -342,6 +626,24 @@ fn spawn_exec_provider_startup_prompt_handler(provider: Provider, workspace: Pat
|
|
|
342
626
|
});
|
|
343
627
|
}
|
|
344
628
|
|
|
629
|
+
fn spawn_managed_provider_startup_prompt_handler(
|
|
630
|
+
provider: Provider,
|
|
631
|
+
workspace: PathBuf,
|
|
632
|
+
pane_id: String,
|
|
633
|
+
) {
|
|
634
|
+
std::thread::spawn(move || {
|
|
635
|
+
let transport = TmuxBackend::for_workspace(&workspace);
|
|
636
|
+
let _ = handle_exec_provider_startup_prompts(
|
|
637
|
+
provider,
|
|
638
|
+
&workspace,
|
|
639
|
+
&pane_id,
|
|
640
|
+
&transport,
|
|
641
|
+
30,
|
|
642
|
+
0.5,
|
|
643
|
+
);
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
345
647
|
fn tmux_transport_for_current_pane() -> TmuxBackend {
|
|
346
648
|
crate::tmux_backend::socket_name_from_tmux_env()
|
|
347
649
|
.map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
|
|
@@ -94,6 +94,7 @@ use super::*;
|
|
|
94
94
|
#[test]
|
|
95
95
|
fn leader_start_mode_serializes_to_python_strings() {
|
|
96
96
|
assert_eq!(serde_json::to_string(&LeaderStartMode::ExecProvider).unwrap(), "\"exec_provider\"");
|
|
97
|
+
assert_eq!(serde_json::to_string(&LeaderStartMode::ManagedTmuxClient).unwrap(), "\"managed_tmux_client\"");
|
|
97
98
|
assert_eq!(serde_json::to_string(&LeaderStartMode::NewTmuxSession).unwrap(), "\"new_tmux_session\"");
|
|
98
99
|
assert_eq!(serde_json::to_string(&LeaderStartMode::AttachExisting).unwrap(), "\"attach_existing\"");
|
|
99
100
|
}
|
|
@@ -59,35 +59,131 @@ use super::*;
|
|
|
59
59
|
#[test]
|
|
60
60
|
#[serial_test::serial(env)]
|
|
61
61
|
fn leader_start_plan_pins_mode_and_leader_env() {
|
|
62
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
63
|
+
let _e = EnvGuard::apply(&[("TMUX", None), ("TMUX_PANE", None)]);
|
|
62
64
|
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_{}", std::process::id()));
|
|
63
65
|
std::fs::create_dir_all(&ws).unwrap();
|
|
64
|
-
let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None).unwrap();
|
|
66
|
+
let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
|
|
65
67
|
assert_eq!(plan.provider, Provider::Fake);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
68
|
+
assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
|
|
69
|
+
assert_eq!(
|
|
70
|
+
plan.session_name.as_ref().map(SessionName::as_str),
|
|
71
|
+
Some("team-current")
|
|
72
|
+
);
|
|
73
|
+
assert_eq!(plan.leader_window.as_ref().map(WindowName::as_str), Some("leader"));
|
|
74
|
+
assert!(!plan.is_external_leader);
|
|
75
|
+
assert!(
|
|
76
|
+
plan.argv.iter().any(|arg| arg == "attach-session"),
|
|
77
|
+
"no-tmux managed launch attaches the user client to the team leader window: {:?}",
|
|
78
|
+
plan.argv
|
|
79
|
+
);
|
|
80
|
+
assert!(
|
|
81
|
+
plan.argv.iter().any(|arg| arg == "team-current:leader"),
|
|
82
|
+
"managed client target must be the team leader window: {:?}",
|
|
83
|
+
plan.argv
|
|
84
|
+
);
|
|
85
|
+
// plan 边界 detached 恒 false(`-d` 插入在 start_leader 层,非此处)。
|
|
86
|
+
assert!(!plan.detached, "leader_start_plan 返回值 detached 恒 false");
|
|
87
|
+
// leader_env 携带 5 个 TEAM_AGENT_* 导出键(_leader_provider_env)。
|
|
88
|
+
for key in [
|
|
89
|
+
"TEAM_AGENT_LEADER_PROVIDER",
|
|
90
|
+
"TEAM_AGENT_LEADER_SESSION_UUID",
|
|
91
|
+
"TEAM_AGENT_MACHINE_FINGERPRINT",
|
|
92
|
+
"TEAM_AGENT_WORKSPACE",
|
|
93
|
+
"TEAM_AGENT_TEAM_ID",
|
|
94
|
+
] {
|
|
95
|
+
assert!(plan.leader_env.contains_key(key), "leader_env 缺导出键 {key}");
|
|
90
96
|
}
|
|
97
|
+
assert_eq!(
|
|
98
|
+
plan.leader_env.get("TEAM_AGENT_LEADER_PROVIDER").map(String::as_str),
|
|
99
|
+
Some("fake")
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#[test]
|
|
104
|
+
#[serial_test::serial(env)]
|
|
105
|
+
fn leader_start_plan_external_leader_keeps_exec_provider_in_tmux() {
|
|
106
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
107
|
+
let _e = EnvGuard::apply(&[("TMUX", Some("/private/tmp/tmux-501/default,88432,187"))]);
|
|
108
|
+
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_external_{}", std::process::id()));
|
|
109
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
110
|
+
|
|
111
|
+
let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, true).unwrap();
|
|
112
|
+
|
|
113
|
+
assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
|
|
114
|
+
assert!(plan.is_external_leader);
|
|
115
|
+
assert_eq!(plan.leader_window, None);
|
|
116
|
+
assert_eq!(plan.argv, vec!["fake".to_string()]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[test]
|
|
120
|
+
#[serial_test::serial(env)]
|
|
121
|
+
fn managed_leader_reuses_existing_team_session_name_without_double_prefix() {
|
|
122
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
123
|
+
let _e = EnvGuard::apply(&[("TMUX", None), ("TMUX_PANE", None)]);
|
|
124
|
+
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_existing_{}", std::process::id()));
|
|
125
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
126
|
+
crate::state::persist::save_runtime_state(
|
|
127
|
+
&ws,
|
|
128
|
+
&serde_json::json!({"session_name": "team-alpha"}),
|
|
129
|
+
)
|
|
130
|
+
.unwrap();
|
|
131
|
+
|
|
132
|
+
let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
|
|
133
|
+
|
|
134
|
+
assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
|
|
135
|
+
assert_eq!(
|
|
136
|
+
plan.session_name.as_ref().map(SessionName::as_str),
|
|
137
|
+
Some("team-alpha")
|
|
138
|
+
);
|
|
139
|
+
assert!(
|
|
140
|
+
!plan.argv.iter().any(|arg| arg.contains("team-team-alpha")),
|
|
141
|
+
"managed session name must not gain a second team- prefix: {:?}",
|
|
142
|
+
plan.argv
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[test]
|
|
147
|
+
#[serial_test::serial(env)]
|
|
148
|
+
fn managed_leader_in_same_tmux_server_switches_client() {
|
|
149
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
150
|
+
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_switch_{}", std::process::id()));
|
|
151
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
152
|
+
let socket = crate::tmux_backend::socket_name_for_workspace(&ws);
|
|
153
|
+
let endpoint = format!("/private/tmp/tmux-501/{socket},88432,187");
|
|
154
|
+
let _e = EnvGuard::apply(&[("TMUX", Some(&endpoint))]);
|
|
155
|
+
|
|
156
|
+
let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
|
|
157
|
+
|
|
158
|
+
assert_eq!(plan.mode, LeaderStartMode::ManagedTmuxClient);
|
|
159
|
+
assert!(
|
|
160
|
+
plan.argv.iter().any(|arg| arg == "switch-client"),
|
|
161
|
+
"same-server managed launch must switch the existing tmux client: {:?}",
|
|
162
|
+
plan.argv
|
|
163
|
+
);
|
|
164
|
+
assert!(
|
|
165
|
+
plan.argv.iter().any(|arg| arg == "team-current:leader"),
|
|
166
|
+
"managed switch target must be the team leader window: {:?}",
|
|
167
|
+
plan.argv
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[test]
|
|
172
|
+
#[serial_test::serial(env)]
|
|
173
|
+
fn managed_leader_in_different_tmux_server_refuses_with_n38_hint() {
|
|
174
|
+
let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
|
175
|
+
let _e = EnvGuard::apply(&[("TMUX", Some("/private/tmp/tmux-501/default,88432,187"))]);
|
|
176
|
+
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_refuse_{}", std::process::id()));
|
|
177
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
178
|
+
|
|
179
|
+
let err = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false)
|
|
180
|
+
.unwrap_err()
|
|
181
|
+
.to_string();
|
|
182
|
+
|
|
183
|
+
assert!(err.contains("Error:"), "{err}");
|
|
184
|
+
assert!(err.contains("Reason:"), "{err}");
|
|
185
|
+
assert!(err.contains("Action:"), "{err}");
|
|
186
|
+
assert!(err.contains("--external-leader"), "{err}");
|
|
91
187
|
}
|
|
92
188
|
|
|
93
189
|
#[test]
|
|
@@ -14,7 +14,7 @@ use super::*;
|
|
|
14
14
|
fn attach_leader_errors_when_pane_not_resolvable() {
|
|
15
15
|
let ws = std::env::temp_dir().join(format!("ta_rs_attach_{}", std::process::id()));
|
|
16
16
|
std::fs::create_dir_all(&ws).unwrap();
|
|
17
|
-
let r = attach_leader(&ws, Some(&PaneId::new("%1")), Provider::Codex);
|
|
17
|
+
let r = attach_leader(&ws, None, Some(&PaneId::new("%1")), Provider::Codex);
|
|
18
18
|
// 不可解析 pane → Err(Validation),错误串提及 pane not found。
|
|
19
19
|
match r {
|
|
20
20
|
Err(LeaderError::Validation(msg)) => {
|
|
@@ -39,7 +39,7 @@ use super::*;
|
|
|
39
39
|
let ws = std::env::temp_dir().join(format!("ta_rs_attach_ok_{}", std::process::id()));
|
|
40
40
|
std::fs::create_dir_all(&ws).unwrap();
|
|
41
41
|
let pane = PaneId::new("%1");
|
|
42
|
-
let r = attach_leader(&ws, Some(&pane), Provider::Codex).unwrap();
|
|
42
|
+
let r = attach_leader(&ws, None, Some(&pane), Provider::Codex).unwrap();
|
|
43
43
|
assert!(r.ok);
|
|
44
44
|
assert_eq!(r.status, LeaseStatus::Claimed);
|
|
45
45
|
let owner = r.owner.as_ref().expect("attach 成功必带 owner");
|
|
@@ -145,6 +145,9 @@ pub enum RereadReason {
|
|
|
145
145
|
pub enum LeaderStartMode {
|
|
146
146
|
/// `os.environ["TMUX"]` 已在 tmux 内 → exec provider in-place。
|
|
147
147
|
ExecProvider,
|
|
148
|
+
/// Managed launcher topology: provider pane lives in the team session's `leader` window;
|
|
149
|
+
/// the invoking terminal is only a tmux client attached/switched to that pane.
|
|
150
|
+
ManagedTmuxClient,
|
|
148
151
|
/// 无 tmux session → 新建 tmux session。
|
|
149
152
|
NewTmuxSession,
|
|
150
153
|
/// session 已存在 / `--attach-session` → attach。
|
|
@@ -331,6 +334,12 @@ pub struct LeaderStartPlan {
|
|
|
331
334
|
pub session_name: Option<SessionName>,
|
|
332
335
|
/// 要 exec 的 argv(provider argv 或 tmux argv)。
|
|
333
336
|
pub argv: Vec<String>,
|
|
337
|
+
/// For managed launches, the provider command argv spawned inside the leader pane.
|
|
338
|
+
pub provider_argv: Vec<String>,
|
|
339
|
+
/// For managed launches, the window that hosts the leader provider pane.
|
|
340
|
+
pub leader_window: Option<WindowName>,
|
|
341
|
+
/// True for external/current-pane leader compatibility paths.
|
|
342
|
+
pub is_external_leader: bool,
|
|
334
343
|
/// `new_tmux_session` / `exec_provider` 的 leader env 导出。
|
|
335
344
|
pub leader_env: BTreeMap<String, String>,
|
|
336
345
|
pub identity: Option<LeaderIdentity>,
|