@team-agent/installer 0.5.66 → 0.5.68
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 +8 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +1 -0
- package/crates/team-agent/src/cli/adapters.rs +5 -0
- package/crates/team-agent/src/cli/diagnose.rs +82 -0
- package/crates/team-agent/src/cli/emit.rs +3 -3
- package/crates/team-agent/src/cli/grok_slot.rs +299 -0
- package/crates/team-agent/src/cli/leader.rs +21 -7
- package/crates/team-agent/src/cli/leaders.rs +125 -1
- package/crates/team-agent/src/cli/mod.rs +20 -0
- package/crates/team-agent/src/cli/send/presentation.rs +7 -1
- package/crates/team-agent/src/cli/spec.rs +2 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
- package/crates/team-agent/src/cli/tests/base.rs +26 -3
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
- package/crates/team-agent/src/communication_mode/mod.rs +6 -4
- package/crates/team-agent/src/compiler.rs +59 -0
- package/crates/team-agent/src/coordinator/backoff.rs +55 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
- package/crates/team-agent/src/coordinator/health.rs +173 -0
- package/crates/team-agent/src/coordinator/mod.rs +31 -0
- package/crates/team-agent/src/coordinator/orphan.rs +31 -0
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +30 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
- package/crates/team-agent/src/coordinator/tick.rs +133 -22
- package/crates/team-agent/src/coordinator/types.rs +49 -0
- package/crates/team-agent/src/db/message_store.rs +39 -5
- package/crates/team-agent/src/layout/worker_env.rs +20 -3
- package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
- package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
- package/crates/team-agent/src/leader/registry.rs +65 -0
- package/crates/team-agent/src/leader/start.rs +920 -63
- package/crates/team-agent/src/lifecycle/display.rs +56 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +332 -12
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
- package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +332 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
- package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
- package/crates/team-agent/src/lifecycle/launch.rs +99 -29
- package/crates/team-agent/src/lifecycle/lock.rs +42 -1
- package/crates/team-agent/src/lifecycle/mod.rs +11 -0
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
- package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
- package/crates/team-agent/src/lifecycle/restart.rs +29 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
- package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
- package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
- package/crates/team-agent/src/lifecycle/tests.rs +23 -13
- package/crates/team-agent/src/lifecycle/types.rs +61 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
- package/crates/team-agent/src/mcp_server/tests.rs +196 -2
- package/crates/team-agent/src/messaging/delivery.rs +405 -40
- package/crates/team-agent/src/messaging/helpers.rs +2 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
- package/crates/team-agent/src/messaging/results.rs +4 -0
- package/crates/team-agent/src/messaging/send.rs +34 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
- package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/types.rs +4 -1
- package/crates/team-agent/src/model/enums.rs +14 -5
- package/crates/team-agent/src/model/permissions.rs +3 -0
- package/crates/team-agent/src/os_probe.rs +73 -2
- package/crates/team-agent/src/provider/adapter.rs +231 -4
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
- package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
- package/crates/team-agent/src/provider/classify.rs +4 -2
- package/crates/team-agent/src/provider/faults.rs +2 -1
- package/crates/team-agent/src/provider/mod.rs +11 -0
- package/crates/team-agent/src/provider/session/capture.rs +158 -20
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
- package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
- package/crates/team-agent/src/provider/session/mod.rs +18 -0
- package/crates/team-agent/src/provider/session/resume.rs +57 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
- package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
- package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
- package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
- package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
- package/crates/team-agent/src/provider/session_scan.rs +8 -0
- package/crates/team-agent/src/provider/submit_now.rs +94 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
- package/crates/team-agent/src/provider/types.rs +4 -2
- package/crates/team-agent/src/provider/wire.rs +23 -1
- package/crates/team-agent/src/state/persist.rs +301 -8
- package/crates/team-agent/src/state/repository.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
- package/crates/team-agent/src/tmux_backend.rs +1064 -75
- package/crates/team-agent/src/transport/tests/wire.rs +28 -2
- package/crates/team-agent/src/transport.rs +248 -1
- package/npm/install.mjs +129 -73
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +33 -238
- package/skills/team-agent/command-coverage.json +31 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
//!
|
|
2
2
|
//! leader::start — leader_start_plan / leader_session_name(派生 tmux session 名)。
|
|
3
|
+
//! Managed launch windows: first leader keeps provider_wire; later live
|
|
4
|
+
//! leaders in the same A-22 session get a unique suffix so attach targets
|
|
5
|
+
//! SESSION:WINDOW without claude_code collisions. state.json records every
|
|
6
|
+
//! live pane in leader_receiver.bound_panes and does not overwrite the first
|
|
7
|
+
//! pane_id.
|
|
3
8
|
|
|
4
9
|
use std::collections::BTreeMap;
|
|
5
10
|
use std::io::{IsTerminal, Read, Write};
|
|
6
11
|
use std::path::{Path, PathBuf};
|
|
7
12
|
use std::process::{Command, Stdio};
|
|
13
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
14
|
+
use std::time::Duration;
|
|
8
15
|
|
|
9
16
|
#[cfg(target_os = "linux")]
|
|
10
17
|
use std::os::fd::AsRawFd;
|
|
@@ -15,7 +22,8 @@ use crate::model::pane_authority_refusal as pane_refusal;
|
|
|
15
22
|
use crate::provider::{get_adapter, Provider};
|
|
16
23
|
use crate::tmux_backend::TmuxBackend;
|
|
17
24
|
use crate::transport::{
|
|
18
|
-
PaneId, PaneInfo, PaneLiveness, SessionName, SpawnResult, Target, Transport,
|
|
25
|
+
CaptureRange, PaneId, PaneInfo, PaneLiveness, SessionName, SpawnResult, Target, Transport,
|
|
26
|
+
WindowName,
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
use super::helpers::{
|
|
@@ -268,12 +276,22 @@ fn leader_start_plan_with_ambient_authority(
|
|
|
268
276
|
LeaderStartMode::NewTmuxSession
|
|
269
277
|
};
|
|
270
278
|
let leader_env = leader_env_for_identity(provider, &identity);
|
|
279
|
+
// First managed leader keeps `provider_wire` (`claude_code`). A second
|
|
280
|
+
// live leader in the same A-22 session gets a nonce suffix so attach
|
|
281
|
+
// targets a unique `SESSION:WINDOW` instead of colliding.
|
|
282
|
+
let managed_window = match (mode, session_name.as_ref()) {
|
|
283
|
+
(LeaderStartMode::ManagedTmuxClient, Some(session)) => Some(
|
|
284
|
+
managed_leader_window_for_launch(provider, workspace, session),
|
|
285
|
+
),
|
|
286
|
+
_ => None,
|
|
287
|
+
};
|
|
271
288
|
let argv = start_argv(
|
|
272
289
|
mode,
|
|
273
290
|
provider,
|
|
274
291
|
provider_args,
|
|
275
292
|
workspace,
|
|
276
293
|
session_name.as_ref(),
|
|
294
|
+
managed_window.as_ref(),
|
|
277
295
|
&leader_env,
|
|
278
296
|
managed_client_attach_mode,
|
|
279
297
|
)?;
|
|
@@ -297,13 +315,9 @@ fn leader_start_plan_with_ambient_authority(
|
|
|
297
315
|
argv,
|
|
298
316
|
provider_argv,
|
|
299
317
|
// 0.3.28 Step 2: leader window inside the dedicated leader session is
|
|
300
|
-
// named after the provider wire (e.g. `
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
// topology violation surface — the worker session never has a window
|
|
304
|
-
// named `leader` either, because the leader session is disjoint.
|
|
305
|
-
leader_window: (mode == LeaderStartMode::ManagedTmuxClient)
|
|
306
|
-
.then(|| WindowName::new(provider_wire(provider))),
|
|
318
|
+
// named after the provider wire (e.g. `claude_code`), never the
|
|
319
|
+
// literal string `leader`. Occupied names get a per-launch suffix.
|
|
320
|
+
leader_window: managed_window,
|
|
307
321
|
is_external_leader: external_path,
|
|
308
322
|
leader_env: plan_env,
|
|
309
323
|
identity: Some(identity),
|
|
@@ -398,7 +412,7 @@ fn execute_leader_plan_after_ambient_authority(
|
|
|
398
412
|
} else if plan.is_external_leader {
|
|
399
413
|
persist_external_leader_topology_marker(plan, workspace)?;
|
|
400
414
|
}
|
|
401
|
-
let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
|
|
415
|
+
let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace, None)?;
|
|
402
416
|
let code = process.status.code();
|
|
403
417
|
if !process.status.success() {
|
|
404
418
|
return Err(LeaderError::Start(leader_launcher_failure(&process)));
|
|
@@ -899,12 +913,67 @@ fn managed_leader_session_from_candidates(
|
|
|
899
913
|
.unwrap_or_else(|| managed_leader_session_name(provider, workspace))
|
|
900
914
|
}
|
|
901
915
|
|
|
916
|
+
/// Base window is `provider_wire` (`claude_code`). A second live leader in
|
|
917
|
+
/// the same session cannot reuse that name: tmux then fails
|
|
918
|
+
/// `attach-session -t SESSION:claude_code` with `can't find window`.
|
|
919
|
+
fn managed_leader_window_from_occupied(
|
|
920
|
+
base: &str,
|
|
921
|
+
occupied: impl IntoIterator<Item = String>,
|
|
922
|
+
nonce: &str,
|
|
923
|
+
) -> WindowName {
|
|
924
|
+
let occupied = occupied
|
|
925
|
+
.into_iter()
|
|
926
|
+
.collect::<std::collections::BTreeSet<_>>();
|
|
927
|
+
if !occupied.contains(base) {
|
|
928
|
+
return WindowName::new(base);
|
|
929
|
+
}
|
|
930
|
+
let mut candidate = format!("{base}-{nonce}");
|
|
931
|
+
let mut n = 2u32;
|
|
932
|
+
while occupied.contains(&candidate) {
|
|
933
|
+
candidate = format!("{base}-{nonce}-{n}");
|
|
934
|
+
n = n.saturating_add(1);
|
|
935
|
+
if n > 32 {
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
WindowName::new(candidate)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
fn managed_leader_window_for_launch(
|
|
943
|
+
provider: Provider,
|
|
944
|
+
workspace: &Path,
|
|
945
|
+
session: &SessionName,
|
|
946
|
+
) -> WindowName {
|
|
947
|
+
let base = provider_wire(provider);
|
|
948
|
+
let transport = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
949
|
+
let session_exists = transport.has_session(session).unwrap_or(false);
|
|
950
|
+
if !session_exists {
|
|
951
|
+
return WindowName::new(base);
|
|
952
|
+
}
|
|
953
|
+
let occupied = match transport.list_windows(session) {
|
|
954
|
+
Ok(windows) => windows
|
|
955
|
+
.into_iter()
|
|
956
|
+
.map(|window| window.as_str().to_string())
|
|
957
|
+
.collect::<Vec<_>>(),
|
|
958
|
+
// Cannot prove the base name is free: suffix rather than collide.
|
|
959
|
+
Err(_) => vec![base.to_string()],
|
|
960
|
+
};
|
|
961
|
+
let pid = std::process::id();
|
|
962
|
+
let epoch_nanos = std::time::SystemTime::now()
|
|
963
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
964
|
+
.map(|d| d.as_nanos())
|
|
965
|
+
.unwrap_or(0);
|
|
966
|
+
let nonce = format!("{pid:x}-{epoch_nanos:x}");
|
|
967
|
+
managed_leader_window_from_occupied(base, occupied, &nonce)
|
|
968
|
+
}
|
|
969
|
+
|
|
902
970
|
fn start_argv(
|
|
903
971
|
mode: LeaderStartMode,
|
|
904
972
|
provider: Provider,
|
|
905
973
|
provider_args: &[String],
|
|
906
974
|
workspace: &Path,
|
|
907
975
|
session_name: Option<&SessionName>,
|
|
976
|
+
leader_window: Option<&WindowName>,
|
|
908
977
|
leader_env: &BTreeMap<String, String>,
|
|
909
978
|
managed_client_attach_mode: Option<ManagedClientAttachMode>,
|
|
910
979
|
) -> Result<Vec<String>, LeaderError> {
|
|
@@ -921,10 +990,15 @@ fn start_argv(
|
|
|
921
990
|
"managed leader session missing".to_string(),
|
|
922
991
|
));
|
|
923
992
|
};
|
|
993
|
+
let Some(window) = leader_window else {
|
|
994
|
+
return Err(LeaderError::Start(
|
|
995
|
+
"managed leader window missing".to_string(),
|
|
996
|
+
));
|
|
997
|
+
};
|
|
924
998
|
let attach_mode = managed_client_attach_mode.ok_or_else(|| {
|
|
925
999
|
LeaderError::Start("managed client attach mode missing".to_string())
|
|
926
1000
|
})?;
|
|
927
|
-
managed_client_argv(workspace, session,
|
|
1001
|
+
managed_client_argv(workspace, session, window, attach_mode)
|
|
928
1002
|
}
|
|
929
1003
|
LeaderStartMode::AttachExisting => {
|
|
930
1004
|
let Some(session) = session_name else {
|
|
@@ -996,13 +1070,13 @@ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = St
|
|
|
996
1070
|
fn managed_client_argv(
|
|
997
1071
|
workspace: &Path,
|
|
998
1072
|
session: &SessionName,
|
|
999
|
-
|
|
1073
|
+
window: &WindowName,
|
|
1000
1074
|
attach_mode: ManagedClientAttachMode,
|
|
1001
1075
|
) -> Result<Vec<String>, LeaderError> {
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
let target = format!("{}:{}", session.as_str(),
|
|
1076
|
+
// Attach to the launch's actual window (base `provider_wire` or a unique
|
|
1077
|
+
// suffix). Targeting only `provider_wire` collides when two live leaders
|
|
1078
|
+
// share the A-22 session (`can't find window: claude_code`).
|
|
1079
|
+
let target = format!("{}:{}", session.as_str(), window.as_str());
|
|
1006
1080
|
let argv = match attach_mode {
|
|
1007
1081
|
ManagedClientAttachMode::SwitchClient => vec![
|
|
1008
1082
|
"tmux".to_string(),
|
|
@@ -1085,7 +1159,20 @@ fn execute_managed_leader_plan_attempt(
|
|
|
1085
1159
|
workspace.to_path_buf(),
|
|
1086
1160
|
spawned.pane_id.as_str().to_string(),
|
|
1087
1161
|
);
|
|
1088
|
-
let process = match run_leader_argv(
|
|
1162
|
+
let process = match run_leader_argv(
|
|
1163
|
+
&plan.argv,
|
|
1164
|
+
&BTreeMap::new(),
|
|
1165
|
+
plan,
|
|
1166
|
+
workspace,
|
|
1167
|
+
Some(ManagedAttachWatch {
|
|
1168
|
+
transport: &transport,
|
|
1169
|
+
spawned: &spawned,
|
|
1170
|
+
session,
|
|
1171
|
+
session_existed_before,
|
|
1172
|
+
workspace,
|
|
1173
|
+
provider_label: provider_command_name(plan.provider),
|
|
1174
|
+
}),
|
|
1175
|
+
) {
|
|
1089
1176
|
Ok(process) => process,
|
|
1090
1177
|
Err(error) => {
|
|
1091
1178
|
cleanup_managed_leader_resources(
|
|
@@ -1099,6 +1186,14 @@ fn execute_managed_leader_plan_attempt(
|
|
|
1099
1186
|
return Err(error);
|
|
1100
1187
|
}
|
|
1101
1188
|
};
|
|
1189
|
+
if process.intentional_teardown {
|
|
1190
|
+
return Ok(LeaderLaunchOutcome {
|
|
1191
|
+
status: LeaderLaunchStatus::Exited,
|
|
1192
|
+
exit_code: Some(0),
|
|
1193
|
+
session_name: plan.session_name.clone(),
|
|
1194
|
+
reason: None,
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1102
1197
|
let code = process.status.code();
|
|
1103
1198
|
if !process.status.success() {
|
|
1104
1199
|
cleanup_managed_leader_resources(
|
|
@@ -1141,14 +1236,16 @@ fn managed_leader_attach_recovery_plan(
|
|
|
1141
1236
|
workspace: &Path,
|
|
1142
1237
|
) -> Result<LeaderStartPlan, LeaderError> {
|
|
1143
1238
|
let session = managed_leader_session_for_launch(plan.provider, workspace);
|
|
1239
|
+
let window = managed_leader_window_for_launch(plan.provider, workspace, &session);
|
|
1144
1240
|
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1145
1241
|
ManagedClientAttachMode::SwitchClient
|
|
1146
1242
|
} else {
|
|
1147
1243
|
ManagedClientAttachMode::AttachSession
|
|
1148
1244
|
};
|
|
1149
|
-
let argv = managed_client_argv(workspace, &session,
|
|
1245
|
+
let argv = managed_client_argv(workspace, &session, &window, attach_mode)?;
|
|
1150
1246
|
let mut retry_plan = plan.clone();
|
|
1151
1247
|
retry_plan.session_name = Some(session);
|
|
1248
|
+
retry_plan.leader_window = Some(window);
|
|
1152
1249
|
retry_plan.argv = argv;
|
|
1153
1250
|
Ok(retry_plan)
|
|
1154
1251
|
}
|
|
@@ -1200,6 +1297,222 @@ fn ensure_managed_leader_pane(
|
|
|
1200
1297
|
}
|
|
1201
1298
|
}
|
|
1202
1299
|
|
|
1300
|
+
struct ManagedAttachWatch<'a> {
|
|
1301
|
+
transport: &'a dyn Transport,
|
|
1302
|
+
spawned: &'a SpawnResult,
|
|
1303
|
+
session: &'a SessionName,
|
|
1304
|
+
session_existed_before: bool,
|
|
1305
|
+
workspace: &'a Path,
|
|
1306
|
+
provider_label: &'a str,
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
#[cfg(unix)]
|
|
1310
|
+
static MANAGED_ATTACH_TEARDOWN: AtomicBool = AtomicBool::new(false);
|
|
1311
|
+
|
|
1312
|
+
#[cfg(unix)]
|
|
1313
|
+
struct ManagedTermGuard {
|
|
1314
|
+
old: libc::sighandler_t,
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
#[cfg(unix)]
|
|
1318
|
+
impl ManagedTermGuard {
|
|
1319
|
+
fn install() -> Self {
|
|
1320
|
+
MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
|
|
1321
|
+
let handler = managed_term_handler as *const () as libc::sighandler_t;
|
|
1322
|
+
let old = unsafe { libc::signal(libc::SIGTERM, handler) };
|
|
1323
|
+
Self { old }
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
#[cfg(unix)]
|
|
1328
|
+
impl Drop for ManagedTermGuard {
|
|
1329
|
+
fn drop(&mut self) {
|
|
1330
|
+
unsafe {
|
|
1331
|
+
libc::signal(libc::SIGTERM, self.old);
|
|
1332
|
+
}
|
|
1333
|
+
MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
#[cfg(unix)]
|
|
1338
|
+
extern "C" fn managed_term_handler(_: libc::c_int) {
|
|
1339
|
+
MANAGED_ATTACH_TEARDOWN.store(true, Ordering::SeqCst);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
|
|
1343
|
+
let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
|
|
1344
|
+
let target = Target::Pane(watch.spawned.pane_id.clone());
|
|
1345
|
+
if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
|
|
1346
|
+
if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
matches!(
|
|
1351
|
+
leader_provider_health(
|
|
1352
|
+
watch.transport,
|
|
1353
|
+
&watch.spawned.pane_id,
|
|
1354
|
+
watch.provider_label
|
|
1355
|
+
),
|
|
1356
|
+
LeaderProviderHealth::ProviderExited | LeaderProviderHealth::Unreachable
|
|
1357
|
+
)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
fn bound_pane_snapshot(
|
|
1361
|
+
pane_id: &str,
|
|
1362
|
+
session_name: &serde_json::Value,
|
|
1363
|
+
window_name: &serde_json::Value,
|
|
1364
|
+
tmux_socket: &serde_json::Value,
|
|
1365
|
+
) -> serde_json::Value {
|
|
1366
|
+
serde_json::json!({
|
|
1367
|
+
"pane_id": pane_id,
|
|
1368
|
+
"session_name": session_name,
|
|
1369
|
+
"window_name": window_name,
|
|
1370
|
+
"tmux_socket": tmux_socket,
|
|
1371
|
+
})
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
fn merge_bound_panes(
|
|
1375
|
+
existing: Option<&serde_json::Value>,
|
|
1376
|
+
new_pane: serde_json::Value,
|
|
1377
|
+
) -> Vec<serde_json::Value> {
|
|
1378
|
+
let new_id = new_pane
|
|
1379
|
+
.get("pane_id")
|
|
1380
|
+
.and_then(serde_json::Value::as_str)
|
|
1381
|
+
.unwrap_or("")
|
|
1382
|
+
.to_string();
|
|
1383
|
+
let mut panes = Vec::new();
|
|
1384
|
+
if let Some(existing) = existing {
|
|
1385
|
+
if let Some(arr) = existing
|
|
1386
|
+
.get("bound_panes")
|
|
1387
|
+
.and_then(serde_json::Value::as_array)
|
|
1388
|
+
{
|
|
1389
|
+
panes.extend(arr.iter().cloned());
|
|
1390
|
+
} else if let Some(old_pane) = existing.get("pane_id").and_then(serde_json::Value::as_str) {
|
|
1391
|
+
panes.push(bound_pane_snapshot(
|
|
1392
|
+
old_pane,
|
|
1393
|
+
existing
|
|
1394
|
+
.get("session_name")
|
|
1395
|
+
.unwrap_or(&serde_json::Value::Null),
|
|
1396
|
+
existing
|
|
1397
|
+
.get("window_name")
|
|
1398
|
+
.unwrap_or(&serde_json::Value::Null),
|
|
1399
|
+
existing
|
|
1400
|
+
.get("tmux_socket")
|
|
1401
|
+
.unwrap_or(&serde_json::Value::Null),
|
|
1402
|
+
));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
if !new_id.is_empty()
|
|
1406
|
+
&& !panes.iter().any(|pane| {
|
|
1407
|
+
pane.get("pane_id").and_then(serde_json::Value::as_str) == Some(new_id.as_str())
|
|
1408
|
+
})
|
|
1409
|
+
{
|
|
1410
|
+
panes.push(new_pane);
|
|
1411
|
+
}
|
|
1412
|
+
panes
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
fn apply_clear_to_leader_receiver(
|
|
1416
|
+
recv: &mut serde_json::Map<String, serde_json::Value>,
|
|
1417
|
+
pane_id: &str,
|
|
1418
|
+
) -> bool {
|
|
1419
|
+
let mut changed = false;
|
|
1420
|
+
if let Some(arr) = recv
|
|
1421
|
+
.get_mut("bound_panes")
|
|
1422
|
+
.and_then(serde_json::Value::as_array_mut)
|
|
1423
|
+
{
|
|
1424
|
+
let before = arr.len();
|
|
1425
|
+
arr.retain(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str) != Some(pane_id));
|
|
1426
|
+
if arr.len() != before {
|
|
1427
|
+
changed = true;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
let scalar_match = recv.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id);
|
|
1431
|
+
if scalar_match {
|
|
1432
|
+
let promoted = recv
|
|
1433
|
+
.get("bound_panes")
|
|
1434
|
+
.and_then(serde_json::Value::as_array)
|
|
1435
|
+
.and_then(|arr| arr.first())
|
|
1436
|
+
.cloned();
|
|
1437
|
+
if let Some(next) = promoted {
|
|
1438
|
+
recv.insert("status".to_string(), serde_json::json!("attached"));
|
|
1439
|
+
recv.insert(
|
|
1440
|
+
"pane_id".to_string(),
|
|
1441
|
+
next.get("pane_id")
|
|
1442
|
+
.cloned()
|
|
1443
|
+
.unwrap_or(serde_json::Value::Null),
|
|
1444
|
+
);
|
|
1445
|
+
recv.insert(
|
|
1446
|
+
"pane".to_string(),
|
|
1447
|
+
next.get("pane_id")
|
|
1448
|
+
.cloned()
|
|
1449
|
+
.unwrap_or(serde_json::Value::Null),
|
|
1450
|
+
);
|
|
1451
|
+
if let Some(session) = next.get("session_name") {
|
|
1452
|
+
recv.insert("session_name".to_string(), session.clone());
|
|
1453
|
+
}
|
|
1454
|
+
if let Some(window) = next.get("window_name") {
|
|
1455
|
+
recv.insert("window_name".to_string(), window.clone());
|
|
1456
|
+
}
|
|
1457
|
+
} else {
|
|
1458
|
+
recv.insert("status".to_string(), serde_json::json!("exited"));
|
|
1459
|
+
recv.remove("pane_id");
|
|
1460
|
+
recv.remove("pane");
|
|
1461
|
+
}
|
|
1462
|
+
changed = true;
|
|
1463
|
+
}
|
|
1464
|
+
changed
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
fn clear_managed_leader_receiver_pane(workspace: &Path, pane_id: &str) {
|
|
1468
|
+
let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
|
|
1469
|
+
return;
|
|
1470
|
+
};
|
|
1471
|
+
let mut changed = false;
|
|
1472
|
+
if let Some(teams) = state
|
|
1473
|
+
.get_mut("teams")
|
|
1474
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1475
|
+
{
|
|
1476
|
+
for (_key, team) in teams.iter_mut() {
|
|
1477
|
+
let Some(team_obj) = team.as_object_mut() else {
|
|
1478
|
+
continue;
|
|
1479
|
+
};
|
|
1480
|
+
if let Some(recv) = team_obj
|
|
1481
|
+
.get_mut("leader_receiver")
|
|
1482
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1483
|
+
{
|
|
1484
|
+
if apply_clear_to_leader_receiver(recv, pane_id) {
|
|
1485
|
+
changed = true;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
if let Some(owner) = team_obj
|
|
1489
|
+
.get_mut("team_owner")
|
|
1490
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1491
|
+
{
|
|
1492
|
+
if owner.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id) {
|
|
1493
|
+
owner.remove("pane_id");
|
|
1494
|
+
changed = true;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
if !changed {
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
let team_key = state
|
|
1503
|
+
.get("active_team_key")
|
|
1504
|
+
.and_then(serde_json::Value::as_str)
|
|
1505
|
+
.unwrap_or("current")
|
|
1506
|
+
.to_string();
|
|
1507
|
+
let _ = crate::state::repository::StateRepository::new(workspace).save(
|
|
1508
|
+
crate::state::repository::StateWriteIntent::LeaderStartBinding {
|
|
1509
|
+
team_key: &team_key,
|
|
1510
|
+
transport_kind: "managed",
|
|
1511
|
+
},
|
|
1512
|
+
&state,
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1203
1516
|
fn cleanup_managed_leader_resources(
|
|
1204
1517
|
transport: &dyn Transport,
|
|
1205
1518
|
session: &SessionName,
|
|
@@ -1208,23 +1521,36 @@ fn cleanup_managed_leader_resources(
|
|
|
1208
1521
|
workspace: &Path,
|
|
1209
1522
|
reason: &str,
|
|
1210
1523
|
) {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
.map(|_| "ok".to_string())
|
|
1217
|
-
.unwrap_or_else(|error| error.to_string()),
|
|
1218
|
-
)
|
|
1219
|
-
} else {
|
|
1220
|
-
(
|
|
1221
|
-
"kill_session",
|
|
1222
|
-
transport
|
|
1223
|
-
.kill_session(session)
|
|
1224
|
-
.map(|_| "ok".to_string())
|
|
1225
|
-
.unwrap_or_else(|error| error.to_string()),
|
|
1226
|
-
)
|
|
1524
|
+
// Own window only — never kill-server (same socket may host other teams).
|
|
1525
|
+
// Last window in the session lets tmux destroy the session itself.
|
|
1526
|
+
let window_target = Target::SessionWindow {
|
|
1527
|
+
session: spawned.session.clone(),
|
|
1528
|
+
window: spawned.window.clone(),
|
|
1227
1529
|
};
|
|
1530
|
+
let window_result = transport
|
|
1531
|
+
.kill_window(&window_target)
|
|
1532
|
+
.map(|_| "ok".to_string())
|
|
1533
|
+
.unwrap_or_else(|error| error.to_string());
|
|
1534
|
+
let mut action = "kill_window".to_string();
|
|
1535
|
+
let mut result = window_result;
|
|
1536
|
+
if !session_existed_before {
|
|
1537
|
+
match transport.list_windows(session) {
|
|
1538
|
+
Ok(windows) if windows.is_empty() => {
|
|
1539
|
+
let session_result = transport
|
|
1540
|
+
.kill_session(session)
|
|
1541
|
+
.map(|_| "ok".to_string())
|
|
1542
|
+
.unwrap_or_else(|error| error.to_string());
|
|
1543
|
+
action = "kill_window+kill_session".to_string();
|
|
1544
|
+
result = format!("{result};kill_session={session_result}");
|
|
1545
|
+
}
|
|
1546
|
+
Ok(_) => {}
|
|
1547
|
+
Err(error) => {
|
|
1548
|
+
action = "kill_window+list_windows_err".to_string();
|
|
1549
|
+
result = format!("{result};list_windows={error}");
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
clear_managed_leader_receiver_pane(workspace, spawned.pane_id.as_str());
|
|
1228
1554
|
write_leader_startup_prompt_event(
|
|
1229
1555
|
workspace,
|
|
1230
1556
|
"leader.launcher.rollback",
|
|
@@ -1411,17 +1737,12 @@ fn persist_managed_leader_binding(
|
|
|
1411
1737
|
.ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
|
|
1412
1738
|
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
1413
1739
|
.unwrap_or_else(|_| serde_json::json!({}));
|
|
1414
|
-
let
|
|
1415
|
-
|
|
1416
|
-
.
|
|
1417
|
-
.
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
.and_then(|owner| owner.get("owner_epoch"))
|
|
1421
|
-
.and_then(serde_json::Value::as_u64)
|
|
1422
|
-
})
|
|
1423
|
-
.unwrap_or(0)
|
|
1424
|
-
.saturating_add(1);
|
|
1740
|
+
let team_key = identity.team_id.as_str().to_string();
|
|
1741
|
+
let existing_recv = state
|
|
1742
|
+
.get("teams")
|
|
1743
|
+
.and_then(|teams| teams.get(&team_key))
|
|
1744
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
1745
|
+
.cloned();
|
|
1425
1746
|
let now = chrono::Utc::now().to_rfc3339();
|
|
1426
1747
|
let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
|
|
1427
1748
|
.map(|path| path.to_string_lossy().to_string())
|
|
@@ -1430,27 +1751,81 @@ fn persist_managed_leader_binding(
|
|
|
1430
1751
|
let session = spawned.session.as_str().to_string();
|
|
1431
1752
|
let window = spawned.window.as_str().to_string();
|
|
1432
1753
|
let pane = spawned.pane_id.as_str().to_string();
|
|
1754
|
+
let keep_existing_scalar = existing_recv
|
|
1755
|
+
.as_ref()
|
|
1756
|
+
.and_then(|recv| recv.get("pane_id"))
|
|
1757
|
+
.and_then(serde_json::Value::as_str)
|
|
1758
|
+
.is_some_and(|old| old != pane);
|
|
1759
|
+
let current_epoch = state
|
|
1760
|
+
.get("owner_epoch")
|
|
1761
|
+
.and_then(serde_json::Value::as_u64)
|
|
1762
|
+
.or_else(|| {
|
|
1763
|
+
state
|
|
1764
|
+
.get("team_owner")
|
|
1765
|
+
.and_then(|owner| owner.get("owner_epoch"))
|
|
1766
|
+
.and_then(serde_json::Value::as_u64)
|
|
1767
|
+
})
|
|
1768
|
+
.unwrap_or(0);
|
|
1769
|
+
let owner_epoch = if keep_existing_scalar {
|
|
1770
|
+
existing_recv
|
|
1771
|
+
.as_ref()
|
|
1772
|
+
.and_then(|recv| recv.get("owner_epoch"))
|
|
1773
|
+
.and_then(serde_json::Value::as_u64)
|
|
1774
|
+
.unwrap_or(current_epoch)
|
|
1775
|
+
} else {
|
|
1776
|
+
current_epoch.saturating_add(1)
|
|
1777
|
+
};
|
|
1433
1778
|
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1434
1779
|
"switch-client"
|
|
1435
1780
|
} else {
|
|
1436
1781
|
"attach-session"
|
|
1437
1782
|
};
|
|
1783
|
+
let new_binding = bound_pane_snapshot(
|
|
1784
|
+
&pane,
|
|
1785
|
+
&serde_json::json!(session),
|
|
1786
|
+
&serde_json::json!(window),
|
|
1787
|
+
&serde_json::json!(socket),
|
|
1788
|
+
);
|
|
1789
|
+
let bound_panes = merge_bound_panes(existing_recv.as_ref(), new_binding);
|
|
1790
|
+
let (scalar_pane, scalar_session, scalar_window, scalar_attached_at) =
|
|
1791
|
+
match existing_recv.as_ref() {
|
|
1792
|
+
Some(recv) if keep_existing_scalar => (
|
|
1793
|
+
recv.get("pane_id")
|
|
1794
|
+
.and_then(serde_json::Value::as_str)
|
|
1795
|
+
.unwrap_or(pane.as_str())
|
|
1796
|
+
.to_string(),
|
|
1797
|
+
recv.get("session_name")
|
|
1798
|
+
.and_then(serde_json::Value::as_str)
|
|
1799
|
+
.unwrap_or(session.as_str())
|
|
1800
|
+
.to_string(),
|
|
1801
|
+
recv.get("window_name")
|
|
1802
|
+
.and_then(serde_json::Value::as_str)
|
|
1803
|
+
.unwrap_or(window.as_str())
|
|
1804
|
+
.to_string(),
|
|
1805
|
+
recv.get("attached_at")
|
|
1806
|
+
.and_then(serde_json::Value::as_str)
|
|
1807
|
+
.unwrap_or(now.as_str())
|
|
1808
|
+
.to_string(),
|
|
1809
|
+
),
|
|
1810
|
+
_ => (pane.clone(), session.clone(), window.clone(), now.clone()),
|
|
1811
|
+
};
|
|
1438
1812
|
let receiver = serde_json::json!({
|
|
1439
1813
|
"mode": "direct_tmux",
|
|
1440
1814
|
"status": "attached",
|
|
1441
1815
|
"provider": provider.clone(),
|
|
1442
|
-
"pane_id":
|
|
1443
|
-
"pane":
|
|
1444
|
-
"session_name":
|
|
1445
|
-
"window_name":
|
|
1816
|
+
"pane_id": scalar_pane,
|
|
1817
|
+
"pane": scalar_pane,
|
|
1818
|
+
"session_name": scalar_session,
|
|
1819
|
+
"window_name": scalar_window,
|
|
1446
1820
|
"tmux_socket": socket,
|
|
1447
1821
|
"leader_session_uuid": identity.leader_session_uuid,
|
|
1448
1822
|
"owner_epoch": owner_epoch,
|
|
1449
|
-
"attached_at":
|
|
1823
|
+
"attached_at": scalar_attached_at,
|
|
1450
1824
|
"discovery": "managed_launcher",
|
|
1825
|
+
"bound_panes": bound_panes,
|
|
1451
1826
|
});
|
|
1452
1827
|
let owner = serde_json::json!({
|
|
1453
|
-
"pane_id":
|
|
1828
|
+
"pane_id": scalar_pane,
|
|
1454
1829
|
"provider": provider.clone(),
|
|
1455
1830
|
"machine_fingerprint": identity.machine_fingerprint,
|
|
1456
1831
|
"leader_session_uuid": identity.leader_session_uuid,
|
|
@@ -1908,6 +2283,7 @@ fn run_leader_argv(
|
|
|
1908
2283
|
env: &BTreeMap<String, String>,
|
|
1909
2284
|
plan: &LeaderStartPlan,
|
|
1910
2285
|
workspace: &Path,
|
|
2286
|
+
managed_watch: Option<ManagedAttachWatch<'_>>,
|
|
1911
2287
|
) -> Result<LeaderProcessExit, LeaderError> {
|
|
1912
2288
|
let Some(program) = argv.first() else {
|
|
1913
2289
|
return Err(LeaderError::Start(
|
|
@@ -2013,7 +2389,13 @@ fn run_leader_argv(
|
|
|
2013
2389
|
if plan.mode == LeaderStartMode::ExecProvider {
|
|
2014
2390
|
spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
|
|
2015
2391
|
}
|
|
2016
|
-
|
|
2392
|
+
#[cfg(unix)]
|
|
2393
|
+
let _term_guard = managed_watch.as_ref().map(|_| ManagedTermGuard::install());
|
|
2394
|
+
let (status, intentional_teardown) = if let Some(watch) = managed_watch.as_ref() {
|
|
2395
|
+
wait_managed_attach_child(&mut child, watch)?
|
|
2396
|
+
} else {
|
|
2397
|
+
(child.wait().map_err(LeaderError::Io)?, false)
|
|
2398
|
+
};
|
|
2017
2399
|
let stdout = match stdout_reader {
|
|
2018
2400
|
Some(reader) => match reader.join() {
|
|
2019
2401
|
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
|
@@ -2050,9 +2432,88 @@ fn run_leader_argv(
|
|
|
2050
2432
|
status,
|
|
2051
2433
|
stderr,
|
|
2052
2434
|
diagnostics_path,
|
|
2435
|
+
intentional_teardown,
|
|
2053
2436
|
})
|
|
2054
2437
|
}
|
|
2055
2438
|
|
|
2439
|
+
fn wait_managed_attach_child(
|
|
2440
|
+
child: &mut std::process::Child,
|
|
2441
|
+
watch: &ManagedAttachWatch<'_>,
|
|
2442
|
+
) -> Result<(std::process::ExitStatus, bool), LeaderError> {
|
|
2443
|
+
loop {
|
|
2444
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2445
|
+
return Ok((status, false));
|
|
2446
|
+
}
|
|
2447
|
+
let signalled = {
|
|
2448
|
+
#[cfg(unix)]
|
|
2449
|
+
{
|
|
2450
|
+
MANAGED_ATTACH_TEARDOWN.load(Ordering::SeqCst)
|
|
2451
|
+
}
|
|
2452
|
+
#[cfg(not(unix))]
|
|
2453
|
+
{
|
|
2454
|
+
false
|
|
2455
|
+
}
|
|
2456
|
+
};
|
|
2457
|
+
if signalled || managed_attach_should_teardown(watch) {
|
|
2458
|
+
// `managed_attach_should_teardown` costs a tmux round-trip, and the
|
|
2459
|
+
// attach child can lose that race: it dies BY ITSELF inside the
|
|
2460
|
+
// window (`open terminal failed: not a terminal`, or a client whose
|
|
2461
|
+
// attach was rejected). Re-check before we touch anything. A child
|
|
2462
|
+
// that already failed on its own was never torn down by us, so it
|
|
2463
|
+
// must NOT be reported as an intentional teardown — its non-zero
|
|
2464
|
+
// status has to reach the launcher failure path
|
|
2465
|
+
// (`execute_managed_leader_plan_attempt`), which is what surfaces the
|
|
2466
|
+
// error and drives attach recovery. Only the FAILING self-exit is
|
|
2467
|
+
// diverted: a clean self-exit keeps the pre-existing behaviour
|
|
2468
|
+
// (cleanup + intentional teardown + exit 0), and an explicit signal
|
|
2469
|
+
// teardown stays intentional regardless.
|
|
2470
|
+
if !signalled {
|
|
2471
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2472
|
+
if !status.success() {
|
|
2473
|
+
return Ok((status, false));
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
let reason = if signalled {
|
|
2478
|
+
"launcher_signal"
|
|
2479
|
+
} else {
|
|
2480
|
+
"provider_exited"
|
|
2481
|
+
};
|
|
2482
|
+
cleanup_managed_leader_resources(
|
|
2483
|
+
watch.transport,
|
|
2484
|
+
watch.session,
|
|
2485
|
+
watch.spawned,
|
|
2486
|
+
watch.session_existed_before,
|
|
2487
|
+
watch.workspace,
|
|
2488
|
+
reason,
|
|
2489
|
+
);
|
|
2490
|
+
let status = terminate_managed_attach_child(child)?;
|
|
2491
|
+
return Ok((status, true));
|
|
2492
|
+
}
|
|
2493
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
fn terminate_managed_attach_child(
|
|
2498
|
+
child: &mut std::process::Child,
|
|
2499
|
+
) -> Result<std::process::ExitStatus, LeaderError> {
|
|
2500
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2501
|
+
return Ok(status);
|
|
2502
|
+
}
|
|
2503
|
+
#[cfg(unix)]
|
|
2504
|
+
unsafe {
|
|
2505
|
+
libc::kill(child.id() as i32, libc::SIGTERM);
|
|
2506
|
+
}
|
|
2507
|
+
for _ in 0..20 {
|
|
2508
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
2509
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2510
|
+
return Ok(status);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
let _ = child.kill();
|
|
2514
|
+
child.wait().map_err(LeaderError::Io)
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2056
2517
|
fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
|
|
2057
2518
|
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
|
|
2058
2519
|
workspace.join(".team").join("logs").join(format!(
|
|
@@ -2092,6 +2553,7 @@ struct LeaderProcessExit {
|
|
|
2092
2553
|
status: std::process::ExitStatus,
|
|
2093
2554
|
stderr: String,
|
|
2094
2555
|
diagnostics_path: PathBuf,
|
|
2556
|
+
intentional_teardown: bool,
|
|
2095
2557
|
}
|
|
2096
2558
|
|
|
2097
2559
|
fn push_bounded_stderr(captured: &mut Vec<u8>, chunk: &[u8]) {
|
|
@@ -2313,6 +2775,8 @@ fn provider_command_name(provider: Provider) -> &'static str {
|
|
|
2313
2775
|
// (leader/start.rs:192-204 派生)自动覆盖前缀保护。
|
|
2314
2776
|
Provider::Copilot => "copilot",
|
|
2315
2777
|
Provider::GeminiCli => "gemini",
|
|
2778
|
+
Provider::Grok => "grok",
|
|
2779
|
+
Provider::CursorAgent => "agent",
|
|
2316
2780
|
Provider::Fake => "fake",
|
|
2317
2781
|
}
|
|
2318
2782
|
}
|
|
@@ -2396,10 +2860,13 @@ mod tests {
|
|
|
2396
2860
|
};
|
|
2397
2861
|
|
|
2398
2862
|
use super::{
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2863
|
+
clear_managed_leader_receiver_pane, ensure_managed_provider_live_after_attach,
|
|
2864
|
+
execute_leader_plan_after_ambient_authority, format_launcher_failure,
|
|
2865
|
+
handle_exec_provider_startup_prompts, is_interactive_shell_basename,
|
|
2866
|
+
lock_down_window_rename, managed_client_argv, managed_leader_window_from_occupied,
|
|
2867
|
+
persist_managed_leader_binding, push_bounded_stderr, shlex_quote,
|
|
2868
|
+
write_window_rename_lockdown_event, ManagedClientAttachMode, VerifiedAmbientPaneAuthority,
|
|
2869
|
+
LEADER_STDERR_LIMIT,
|
|
2403
2870
|
};
|
|
2404
2871
|
|
|
2405
2872
|
struct ScriptedTransport {
|
|
@@ -2409,6 +2876,8 @@ mod tests {
|
|
|
2409
2876
|
targets: Vec<PaneInfo>,
|
|
2410
2877
|
window_options: Mutex<Vec<(String, String)>>,
|
|
2411
2878
|
window_option_error: Mutex<Option<String>>,
|
|
2879
|
+
kills: Mutex<Vec<String>>,
|
|
2880
|
+
windows: Mutex<Vec<WindowName>>,
|
|
2412
2881
|
}
|
|
2413
2882
|
|
|
2414
2883
|
impl ScriptedTransport {
|
|
@@ -2420,6 +2889,8 @@ mod tests {
|
|
|
2420
2889
|
targets: Vec::new(),
|
|
2421
2890
|
window_options: Mutex::new(Vec::new()),
|
|
2422
2891
|
window_option_error: Mutex::new(None),
|
|
2892
|
+
kills: Mutex::new(Vec::new()),
|
|
2893
|
+
windows: Mutex::new(Vec::new()),
|
|
2423
2894
|
}
|
|
2424
2895
|
}
|
|
2425
2896
|
|
|
@@ -2431,6 +2902,8 @@ mod tests {
|
|
|
2431
2902
|
targets: Vec::new(),
|
|
2432
2903
|
window_options: Mutex::new(Vec::new()),
|
|
2433
2904
|
window_option_error: Mutex::new(None),
|
|
2905
|
+
kills: Mutex::new(Vec::new()),
|
|
2906
|
+
windows: Mutex::new(Vec::new()),
|
|
2434
2907
|
}
|
|
2435
2908
|
}
|
|
2436
2909
|
|
|
@@ -2442,6 +2915,8 @@ mod tests {
|
|
|
2442
2915
|
targets,
|
|
2443
2916
|
window_options: Mutex::new(Vec::new()),
|
|
2444
2917
|
window_option_error: Mutex::new(None),
|
|
2918
|
+
kills: Mutex::new(Vec::new()),
|
|
2919
|
+
windows: Mutex::new(Vec::new()),
|
|
2445
2920
|
}
|
|
2446
2921
|
}
|
|
2447
2922
|
|
|
@@ -2455,6 +2930,8 @@ mod tests {
|
|
|
2455
2930
|
targets: Vec::new(),
|
|
2456
2931
|
window_options: Mutex::new(Vec::new()),
|
|
2457
2932
|
window_option_error: Mutex::new(Some(error.to_string())),
|
|
2933
|
+
kills: Mutex::new(Vec::new()),
|
|
2934
|
+
windows: Mutex::new(Vec::new()),
|
|
2458
2935
|
}
|
|
2459
2936
|
}
|
|
2460
2937
|
|
|
@@ -2472,6 +2949,20 @@ mod tests {
|
|
|
2472
2949
|
Err(poisoned) => poisoned.into_inner().clone(),
|
|
2473
2950
|
}
|
|
2474
2951
|
}
|
|
2952
|
+
|
|
2953
|
+
fn record_kill(&self, action: &str) {
|
|
2954
|
+
match self.kills.lock() {
|
|
2955
|
+
Ok(mut guard) => guard.push(action.to_string()),
|
|
2956
|
+
Err(poisoned) => poisoned.into_inner().push(action.to_string()),
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
fn kills(&self) -> Vec<String> {
|
|
2961
|
+
match self.kills.lock() {
|
|
2962
|
+
Ok(guard) => guard.clone(),
|
|
2963
|
+
Err(poisoned) => poisoned.into_inner().clone(),
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2475
2966
|
}
|
|
2476
2967
|
|
|
2477
2968
|
impl Transport for ScriptedTransport {
|
|
@@ -2576,7 +3067,10 @@ mod tests {
|
|
|
2576
3067
|
}
|
|
2577
3068
|
|
|
2578
3069
|
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
2579
|
-
|
|
3070
|
+
match self.windows.lock() {
|
|
3071
|
+
Ok(guard) => Ok(guard.clone()),
|
|
3072
|
+
Err(poisoned) => Ok(poisoned.into_inner().clone()),
|
|
3073
|
+
}
|
|
2580
3074
|
}
|
|
2581
3075
|
|
|
2582
3076
|
fn set_session_env(
|
|
@@ -2616,11 +3110,22 @@ mod tests {
|
|
|
2616
3110
|
Ok(())
|
|
2617
3111
|
}
|
|
2618
3112
|
|
|
2619
|
-
fn
|
|
3113
|
+
fn kill_server(&self) -> Result<(), TransportError> {
|
|
3114
|
+
self.record_kill("kill_server");
|
|
3115
|
+
Ok(())
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
|
|
3119
|
+
self.record_kill(&format!("kill_session:{}", session.as_str()));
|
|
2620
3120
|
Ok(())
|
|
2621
3121
|
}
|
|
2622
3122
|
|
|
2623
|
-
fn kill_window(&self,
|
|
3123
|
+
fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
|
|
3124
|
+
self.record_kill(&format!("kill_window:{target:?}"));
|
|
3125
|
+
match self.windows.lock() {
|
|
3126
|
+
Ok(mut guard) => guard.clear(),
|
|
3127
|
+
Err(poisoned) => poisoned.into_inner().clear(),
|
|
3128
|
+
}
|
|
2624
3129
|
Ok(())
|
|
2625
3130
|
}
|
|
2626
3131
|
|
|
@@ -3130,7 +3635,10 @@ mod tests {
|
|
|
3130
3635
|
// (recorded for audit) instead of propagating an error.
|
|
3131
3636
|
let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
|
|
3132
3637
|
|
|
3133
|
-
assert_eq!(
|
|
3638
|
+
assert_eq!(
|
|
3639
|
+
result, "failed",
|
|
3640
|
+
"a set-window-option failure is audited, not fatal"
|
|
3641
|
+
);
|
|
3134
3642
|
assert!(
|
|
3135
3643
|
stderr.contains("unknown option"),
|
|
3136
3644
|
"tmux stderr must surface in the audit payload: {stderr}"
|
|
@@ -3141,16 +3649,365 @@ mod tests {
|
|
|
3141
3649
|
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
3142
3650
|
let lockdown = events
|
|
3143
3651
|
.iter()
|
|
3144
|
-
.find(|event|
|
|
3145
|
-
event["event"] == "leader.launcher.window_rename_lockdown"
|
|
3146
|
-
})
|
|
3652
|
+
.find(|event| event["event"] == "leader.launcher.window_rename_lockdown")
|
|
3147
3653
|
.expect("rename_lockdown event must be written even on failure");
|
|
3148
3654
|
assert_eq!(lockdown["result"], "failed");
|
|
3149
3655
|
assert!(
|
|
3150
|
-
lockdown["tmux_stderr"]
|
|
3656
|
+
lockdown["tmux_stderr"]
|
|
3657
|
+
.as_str()
|
|
3658
|
+
.unwrap_or_default()
|
|
3659
|
+
.contains("unknown option"),
|
|
3151
3660
|
"event must carry tmux stderr for regression triage: {lockdown}"
|
|
3152
3661
|
);
|
|
3153
3662
|
|
|
3154
3663
|
std::fs::remove_dir_all(&workspace).ok();
|
|
3155
3664
|
}
|
|
3665
|
+
|
|
3666
|
+
#[test]
|
|
3667
|
+
fn cleanup_managed_leader_kills_own_window_not_server() {
|
|
3668
|
+
let workspace =
|
|
3669
|
+
std::env::temp_dir().join(format!("ta-cleanup-own-window-{}", std::process::id()));
|
|
3670
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
3671
|
+
let spawned = managed_spawn_result();
|
|
3672
|
+
let transport = ScriptedTransport::with_liveness(PaneLiveness::Live);
|
|
3673
|
+
super::cleanup_managed_leader_resources(
|
|
3674
|
+
&transport,
|
|
3675
|
+
&spawned.session,
|
|
3676
|
+
&spawned,
|
|
3677
|
+
false,
|
|
3678
|
+
&workspace,
|
|
3679
|
+
"test_own_window",
|
|
3680
|
+
);
|
|
3681
|
+
let kills = transport.kills();
|
|
3682
|
+
assert!(
|
|
3683
|
+
kills.iter().any(|k| k.starts_with("kill_window:")),
|
|
3684
|
+
"cleanup must kill the launched window: {kills:?}"
|
|
3685
|
+
);
|
|
3686
|
+
assert!(
|
|
3687
|
+
!kills.iter().any(|k| k == "kill_server"),
|
|
3688
|
+
"cleanup must not kill-server: {kills:?}"
|
|
3689
|
+
);
|
|
3690
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
#[test]
|
|
3694
|
+
fn cleanup_managed_leader_clears_stale_pane_pointer() {
|
|
3695
|
+
let workspace =
|
|
3696
|
+
std::env::temp_dir().join(format!("ta-cleanup-pane-pointer-{}", std::process::id()));
|
|
3697
|
+
std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
|
|
3698
|
+
let state = serde_json::json!({
|
|
3699
|
+
"active_team_key": "current",
|
|
3700
|
+
"teams": {
|
|
3701
|
+
"current": {
|
|
3702
|
+
"leader_receiver": {
|
|
3703
|
+
"status": "attached",
|
|
3704
|
+
"pane_id": "%42",
|
|
3705
|
+
"pane": "%42"
|
|
3706
|
+
},
|
|
3707
|
+
"team_owner": { "pane_id": "%42" }
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
});
|
|
3711
|
+
std::fs::write(
|
|
3712
|
+
crate::state::persist::runtime_state_path(&workspace),
|
|
3713
|
+
serde_json::to_vec_pretty(&state).unwrap(),
|
|
3714
|
+
)
|
|
3715
|
+
.unwrap();
|
|
3716
|
+
super::clear_managed_leader_receiver_pane(&workspace, "%42");
|
|
3717
|
+
let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3718
|
+
let recv = &loaded["teams"]["current"]["leader_receiver"];
|
|
3719
|
+
assert_eq!(recv["status"], serde_json::json!("exited"));
|
|
3720
|
+
assert!(
|
|
3721
|
+
recv.get("pane_id").is_none(),
|
|
3722
|
+
"dangling pane_id must be gone: {recv}"
|
|
3723
|
+
);
|
|
3724
|
+
assert!(
|
|
3725
|
+
loaded["teams"]["current"]["team_owner"]
|
|
3726
|
+
.get("pane_id")
|
|
3727
|
+
.is_none(),
|
|
3728
|
+
"owner pane_id must be gone: {loaded}"
|
|
3729
|
+
);
|
|
3730
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
#[test]
|
|
3734
|
+
fn managed_attach_teardown_detects_provider_exit_marker() {
|
|
3735
|
+
let spawned = managed_spawn_result();
|
|
3736
|
+
let transport = ScriptedTransport::new(vec![
|
|
3737
|
+
"[team-agent] claude exited with 1\n[team-agent] Provider exited; this pane no longer accepts input.".to_string(),
|
|
3738
|
+
]);
|
|
3739
|
+
let watch = super::ManagedAttachWatch {
|
|
3740
|
+
transport: &transport,
|
|
3741
|
+
spawned: &spawned,
|
|
3742
|
+
session: &spawned.session,
|
|
3743
|
+
session_existed_before: false,
|
|
3744
|
+
workspace: Path::new("/tmp"),
|
|
3745
|
+
provider_label: "claude",
|
|
3746
|
+
};
|
|
3747
|
+
assert!(
|
|
3748
|
+
super::managed_attach_should_teardown(&watch),
|
|
3749
|
+
"exit marker in capture must trigger teardown"
|
|
3750
|
+
);
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
#[test]
|
|
3754
|
+
fn managed_attach_teardown_does_not_fire_on_live_provider_screen() {
|
|
3755
|
+
let spawned = managed_spawn_result();
|
|
3756
|
+
let transport = ScriptedTransport::new(vec!["Welcome to Claude Code".to_string()]);
|
|
3757
|
+
let watch = super::ManagedAttachWatch {
|
|
3758
|
+
transport: &transport,
|
|
3759
|
+
spawned: &spawned,
|
|
3760
|
+
session: &spawned.session,
|
|
3761
|
+
session_existed_before: false,
|
|
3762
|
+
workspace: Path::new("/tmp"),
|
|
3763
|
+
provider_label: "claude",
|
|
3764
|
+
};
|
|
3765
|
+
assert!(
|
|
3766
|
+
!super::managed_attach_should_teardown(&watch),
|
|
3767
|
+
"live welcome screen must not teardown"
|
|
3768
|
+
);
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
#[test]
|
|
3772
|
+
fn managed_attach_wait_watches_provider_exit_grep_guard() {
|
|
3773
|
+
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
3774
|
+
let start_rs = manifest.join("src").join("leader").join("start.rs");
|
|
3775
|
+
let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
|
|
3776
|
+
let start = contents
|
|
3777
|
+
.find("fn run_leader_argv(")
|
|
3778
|
+
.expect("run_leader_argv must exist");
|
|
3779
|
+
let end = contents[start + 1..]
|
|
3780
|
+
.find("\nfn ")
|
|
3781
|
+
.map(|offset| start + 1 + offset)
|
|
3782
|
+
.unwrap_or(contents.len());
|
|
3783
|
+
let body = &contents[start..end];
|
|
3784
|
+
assert!(
|
|
3785
|
+
body.contains("wait_managed_attach_child"),
|
|
3786
|
+
"managed attach wait must watch provider lifetime; body excerpt: {body}"
|
|
3787
|
+
);
|
|
3788
|
+
let cleanup = contents
|
|
3789
|
+
.find("fn cleanup_managed_leader_resources(")
|
|
3790
|
+
.expect("cleanup_managed_leader_resources must exist");
|
|
3791
|
+
let cleanup_end = contents[cleanup + 1..]
|
|
3792
|
+
.find("\nfn ")
|
|
3793
|
+
.map(|offset| cleanup + 1 + offset)
|
|
3794
|
+
.unwrap_or(contents.len());
|
|
3795
|
+
let cleanup_body = &contents[cleanup..cleanup_end];
|
|
3796
|
+
assert!(
|
|
3797
|
+
!cleanup_body.contains("kill_server"),
|
|
3798
|
+
"cleanup must not call kill_server; body excerpt: {cleanup_body}"
|
|
3799
|
+
);
|
|
3800
|
+
assert!(
|
|
3801
|
+
cleanup_body.contains("kill_window"),
|
|
3802
|
+
"cleanup must kill own window; body excerpt: {cleanup_body}"
|
|
3803
|
+
);
|
|
3804
|
+
assert!(
|
|
3805
|
+
cleanup_body.contains("clear_managed_leader_receiver_pane"),
|
|
3806
|
+
"cleanup must clear state.json pane pointer; body excerpt: {cleanup_body}"
|
|
3807
|
+
);
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
fn persist_test_identity(workspace: &Path) -> LeaderIdentity {
|
|
3811
|
+
LeaderIdentity {
|
|
3812
|
+
leader_session_uuid: LeaderSessionUuid::derive(
|
|
3813
|
+
"fp",
|
|
3814
|
+
&workspace.to_string_lossy(),
|
|
3815
|
+
"tester",
|
|
3816
|
+
"current",
|
|
3817
|
+
)
|
|
3818
|
+
.unwrap(),
|
|
3819
|
+
leader_session_uuid_source: LeaderSessionUuidSource::Derived,
|
|
3820
|
+
machine_fingerprint: "fp".to_string(),
|
|
3821
|
+
workspace_abspath: workspace.to_path_buf(),
|
|
3822
|
+
os_user: "tester".to_string(),
|
|
3823
|
+
team_id: TeamKey::new("current"),
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
fn persist_test_plan(workspace: &Path) -> LeaderStartPlan {
|
|
3828
|
+
LeaderStartPlan {
|
|
3829
|
+
mode: LeaderStartMode::ManagedTmuxClient,
|
|
3830
|
+
provider: Provider::ClaudeCode,
|
|
3831
|
+
workspace: workspace.to_path_buf(),
|
|
3832
|
+
socket: LeaderLaunchSocket::Workspace,
|
|
3833
|
+
session_name: Some(SessionName::new("team-agent-leader-claude_code-demo")),
|
|
3834
|
+
argv: vec!["tmux".to_string(), "attach-session".to_string()],
|
|
3835
|
+
provider_argv: vec!["claude".to_string()],
|
|
3836
|
+
leader_window: Some(WindowName::new("claude_code")),
|
|
3837
|
+
is_external_leader: false,
|
|
3838
|
+
leader_env: BTreeMap::new(),
|
|
3839
|
+
identity: Some(persist_test_identity(workspace)),
|
|
3840
|
+
detached: false,
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
#[test]
|
|
3845
|
+
fn multi_leader_window_keeps_base_when_free() {
|
|
3846
|
+
let window =
|
|
3847
|
+
managed_leader_window_from_occupied("claude_code", Vec::<String>::new(), "deadbeef");
|
|
3848
|
+
assert_eq!(window.as_str(), "claude_code");
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
#[test]
|
|
3852
|
+
fn multi_leader_window_suffixes_when_taken() {
|
|
3853
|
+
let window = managed_leader_window_from_occupied(
|
|
3854
|
+
"claude_code",
|
|
3855
|
+
vec!["claude_code".to_string()],
|
|
3856
|
+
"aa-bb",
|
|
3857
|
+
);
|
|
3858
|
+
assert_eq!(window.as_str(), "claude_code-aa-bb");
|
|
3859
|
+
assert_ne!(window.as_str(), "claude_code");
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
#[test]
|
|
3863
|
+
fn multi_leader_attach_targets_unique_window() {
|
|
3864
|
+
let session = SessionName::new("team-agent-leader-claude_code-demo");
|
|
3865
|
+
let window = WindowName::new("claude_code-aa-bb");
|
|
3866
|
+
let argv = managed_client_argv(
|
|
3867
|
+
Path::new("/tmp"),
|
|
3868
|
+
&session,
|
|
3869
|
+
&window,
|
|
3870
|
+
ManagedClientAttachMode::AttachSession,
|
|
3871
|
+
)
|
|
3872
|
+
.expect("argv");
|
|
3873
|
+
let target = argv
|
|
3874
|
+
.windows(2)
|
|
3875
|
+
.find(|pair| pair[0] == "-t")
|
|
3876
|
+
.map(|pair| pair[1].as_str())
|
|
3877
|
+
.expect("-t target");
|
|
3878
|
+
assert_eq!(
|
|
3879
|
+
target,
|
|
3880
|
+
"team-agent-leader-claude_code-demo:claude_code-aa-bb"
|
|
3881
|
+
);
|
|
3882
|
+
assert!(
|
|
3883
|
+
!target.ends_with(":claude_code"),
|
|
3884
|
+
"attach must not collapse to the colliding provider_wire name: {target}"
|
|
3885
|
+
);
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3888
|
+
#[test]
|
|
3889
|
+
fn multi_leader_persist_keeps_first_pane_id() {
|
|
3890
|
+
let workspace =
|
|
3891
|
+
std::env::temp_dir().join(format!("ta-multi-leader-persist-{}", std::process::id()));
|
|
3892
|
+
std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
|
|
3893
|
+
let plan = persist_test_plan(&workspace);
|
|
3894
|
+
let first = SpawnResult {
|
|
3895
|
+
pane_id: PaneId::new("%0"),
|
|
3896
|
+
session: SessionName::new("team-agent-leader-claude_code-demo"),
|
|
3897
|
+
window: WindowName::new("claude_code"),
|
|
3898
|
+
child_pid: Some(1),
|
|
3899
|
+
};
|
|
3900
|
+
persist_managed_leader_binding(&plan, &workspace, &first).expect("first persist");
|
|
3901
|
+
let second = SpawnResult {
|
|
3902
|
+
pane_id: PaneId::new("%1"),
|
|
3903
|
+
session: SessionName::new("team-agent-leader-claude_code-demo"),
|
|
3904
|
+
window: WindowName::new("claude_code-aa-bb"),
|
|
3905
|
+
child_pid: Some(2),
|
|
3906
|
+
};
|
|
3907
|
+
persist_managed_leader_binding(&plan, &workspace, &second).expect("second persist");
|
|
3908
|
+
let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3909
|
+
let recv = &loaded["teams"]["current"]["leader_receiver"];
|
|
3910
|
+
assert_eq!(
|
|
3911
|
+
recv["pane_id"],
|
|
3912
|
+
serde_json::json!("%0"),
|
|
3913
|
+
"second launch must not overwrite the first pane pointer: {recv}"
|
|
3914
|
+
);
|
|
3915
|
+
let panes = recv["bound_panes"].as_array().expect("bound_panes");
|
|
3916
|
+
let ids: Vec<&str> = panes
|
|
3917
|
+
.iter()
|
|
3918
|
+
.filter_map(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str))
|
|
3919
|
+
.collect();
|
|
3920
|
+
assert!(ids.contains(&"%0"), "bound_panes missing first: {recv}");
|
|
3921
|
+
assert!(ids.contains(&"%1"), "bound_panes missing second: {recv}");
|
|
3922
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3923
|
+
}
|
|
3924
|
+
|
|
3925
|
+
#[test]
|
|
3926
|
+
fn multi_leader_clear_promotes_remaining() {
|
|
3927
|
+
let workspace =
|
|
3928
|
+
std::env::temp_dir().join(format!("ta-multi-leader-promote-{}", std::process::id()));
|
|
3929
|
+
std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
|
|
3930
|
+
let state = serde_json::json!({
|
|
3931
|
+
"active_team_key": "current",
|
|
3932
|
+
"teams": {
|
|
3933
|
+
"current": {
|
|
3934
|
+
"leader_receiver": {
|
|
3935
|
+
"status": "attached",
|
|
3936
|
+
"pane_id": "%0",
|
|
3937
|
+
"pane": "%0",
|
|
3938
|
+
"session_name": "s",
|
|
3939
|
+
"window_name": "claude_code",
|
|
3940
|
+
"bound_panes": [
|
|
3941
|
+
{
|
|
3942
|
+
"pane_id": "%0",
|
|
3943
|
+
"session_name": "s",
|
|
3944
|
+
"window_name": "claude_code"
|
|
3945
|
+
},
|
|
3946
|
+
{
|
|
3947
|
+
"pane_id": "%1",
|
|
3948
|
+
"session_name": "s",
|
|
3949
|
+
"window_name": "claude_code-2"
|
|
3950
|
+
}
|
|
3951
|
+
]
|
|
3952
|
+
},
|
|
3953
|
+
"team_owner": { "pane_id": "%0" }
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
});
|
|
3957
|
+
std::fs::write(
|
|
3958
|
+
crate::state::persist::runtime_state_path(&workspace),
|
|
3959
|
+
serde_json::to_vec_pretty(&state).unwrap(),
|
|
3960
|
+
)
|
|
3961
|
+
.unwrap();
|
|
3962
|
+
clear_managed_leader_receiver_pane(&workspace, "%0");
|
|
3963
|
+
let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3964
|
+
let recv = &loaded["teams"]["current"]["leader_receiver"];
|
|
3965
|
+
assert_eq!(recv["pane_id"], serde_json::json!("%1"));
|
|
3966
|
+
assert_eq!(recv["status"], serde_json::json!("attached"));
|
|
3967
|
+
let panes = recv["bound_panes"].as_array().expect("bound_panes");
|
|
3968
|
+
assert_eq!(panes.len(), 1);
|
|
3969
|
+
assert_eq!(panes[0]["pane_id"], serde_json::json!("%1"));
|
|
3970
|
+
assert!(
|
|
3971
|
+
loaded["teams"]["current"]["team_owner"]
|
|
3972
|
+
.get("pane_id")
|
|
3973
|
+
.is_none(),
|
|
3974
|
+
"owner pane for the exited leader must be gone: {loaded}"
|
|
3975
|
+
);
|
|
3976
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
#[test]
|
|
3980
|
+
fn multi_leader_launch_window_dedup_contract() {
|
|
3981
|
+
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
3982
|
+
let start_rs = manifest.join("src").join("leader").join("start.rs");
|
|
3983
|
+
let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
|
|
3984
|
+
let start = contents
|
|
3985
|
+
.find("fn leader_start_plan_with_ambient_authority(")
|
|
3986
|
+
.expect("leader_start_plan_with_ambient_authority must exist");
|
|
3987
|
+
let end = contents[start + 1..]
|
|
3988
|
+
.find("\nfn ")
|
|
3989
|
+
.map(|offset| start + 1 + offset)
|
|
3990
|
+
.unwrap_or(contents.len());
|
|
3991
|
+
let body = &contents[start..end];
|
|
3992
|
+
assert!(
|
|
3993
|
+
body.contains("managed_leader_window_for_launch(provider, workspace, session)"),
|
|
3994
|
+
"managed launch must pick a unique window in the A-22 session; body excerpt: {body}"
|
|
3995
|
+
);
|
|
3996
|
+
let argv_start = contents
|
|
3997
|
+
.find("fn managed_client_argv(")
|
|
3998
|
+
.expect("managed_client_argv must exist");
|
|
3999
|
+
let argv_end = contents[argv_start + 1..]
|
|
4000
|
+
.find("\nfn ")
|
|
4001
|
+
.map(|offset| argv_start + 1 + offset)
|
|
4002
|
+
.unwrap_or(contents.len());
|
|
4003
|
+
let argv_body = &contents[argv_start..argv_end];
|
|
4004
|
+
assert!(
|
|
4005
|
+
argv_body.contains("window.as_str()"),
|
|
4006
|
+
"attach target must use the launch window, not a hardcoded wire name; body excerpt: {argv_body}"
|
|
4007
|
+
);
|
|
4008
|
+
assert!(
|
|
4009
|
+
!argv_body.contains("provider_wire(provider)"),
|
|
4010
|
+
"attach target must not collapse to provider_wire; body excerpt: {argv_body}"
|
|
4011
|
+
);
|
|
4012
|
+
}
|
|
3156
4013
|
}
|