@team-agent/installer 0.5.66 → 0.5.69
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 +156 -0
- package/crates/team-agent/src/cli/emit.rs +6 -5
- package/crates/team-agent/src/cli/grok_slot.rs +299 -0
- package/crates/team-agent/src/cli/leader.rs +22 -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 +3 -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 +121 -5
- 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 +31 -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 +3 -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 +973 -75
- 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 +398 -26
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +80 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +60 -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 +548 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +208 -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 +482 -1
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -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 +87 -3
- 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 +127 -25
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +140 -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 +107 -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 +99 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +52 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +214 -160
- package/crates/team-agent/src/lifecycle/restart/common.rs +513 -37
- 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 +233 -6
- 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/claude_profile_launch_red.rs +24 -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 +464 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +247 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +787 -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 +485 -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/harvest2_a_batch_red.rs +1 -1
- 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/pi_catalog_red.rs +97 -0
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
- package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +3 -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 +30 -13
- package/crates/team-agent/src/lifecycle/types.rs +61 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +143 -12
- 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 +430 -41
- 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 +17 -6
- 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 +259 -6
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +89 -0
- package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +3 -0
- package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +47 -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 +78 -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 +205 -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/pi.rs +172 -0
- package/crates/team-agent/src/provider/session_scan.rs +12 -0
- package/crates/team-agent/src/provider/submit_now.rs +94 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +384 -0
- package/crates/team-agent/src/provider/types.rs +4 -2
- package/crates/team-agent/src/provider/wire.rs +29 -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 +1116 -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 +32 -239
- 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,70 @@ 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
|
+
};
|
|
288
|
+
let provider_argv = if provider == Provider::Pi && mode != LeaderStartMode::AttachExisting {
|
|
289
|
+
let parsed = crate::lifecycle::launch::pi_mcp::parse_pi_leader_args(provider_args)
|
|
290
|
+
.map_err(|error| LeaderError::Start(error.to_string()))?;
|
|
291
|
+
let mcp_config = adapter
|
|
292
|
+
.mcp_config(crate::model::enums::AuthMode::Subscription)
|
|
293
|
+
.map_err(|error| LeaderError::Start(error.to_string()))?;
|
|
294
|
+
let mcp_config = crate::lifecycle::launch::resolve_mcp_config(
|
|
295
|
+
mcp_config,
|
|
296
|
+
workspace,
|
|
297
|
+
"leader",
|
|
298
|
+
identity.team_id.as_str(),
|
|
299
|
+
);
|
|
300
|
+
let prompt = crate::lifecycle::worker_command_context::compile_pi_leader_system_prompt();
|
|
301
|
+
let tools = ["mcp_team", "fs_read", "fs_list", "fs_write", "execute_bash"];
|
|
302
|
+
let team_mcp_tools = [
|
|
303
|
+
"assign_task",
|
|
304
|
+
"send_message",
|
|
305
|
+
"update_state",
|
|
306
|
+
"get_team_status",
|
|
307
|
+
"stop_agent",
|
|
308
|
+
"reset_agent",
|
|
309
|
+
"add_agent",
|
|
310
|
+
"clone_agent",
|
|
311
|
+
"fork_agent",
|
|
312
|
+
"request_human",
|
|
313
|
+
"stuck_list",
|
|
314
|
+
"stuck_cancel",
|
|
315
|
+
];
|
|
316
|
+
crate::lifecycle::launch::pi_mcp::materialize_pi_plan(
|
|
317
|
+
crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
|
|
318
|
+
workspace,
|
|
319
|
+
team_id: identity.team_id.as_str(),
|
|
320
|
+
agent_id: "leader",
|
|
321
|
+
model: &parsed.model,
|
|
322
|
+
effort: parsed.effort,
|
|
323
|
+
system_prompt: &prompt,
|
|
324
|
+
tool_categories: &tools,
|
|
325
|
+
team_mcp_tools: &team_mcp_tools,
|
|
326
|
+
mcp_config: &mcp_config,
|
|
327
|
+
},
|
|
328
|
+
)
|
|
329
|
+
.map_err(|error| LeaderError::Start(error.to_string()))?
|
|
330
|
+
.argv
|
|
331
|
+
} else if provider == Provider::Pi {
|
|
332
|
+
Vec::new()
|
|
333
|
+
} else {
|
|
334
|
+
provider_command_argv(provider, provider_args)
|
|
335
|
+
};
|
|
271
336
|
let argv = start_argv(
|
|
272
337
|
mode,
|
|
273
338
|
provider,
|
|
274
|
-
|
|
339
|
+
&provider_argv,
|
|
275
340
|
workspace,
|
|
276
341
|
session_name.as_ref(),
|
|
342
|
+
managed_window.as_ref(),
|
|
277
343
|
&leader_env,
|
|
278
344
|
managed_client_attach_mode,
|
|
279
345
|
)?;
|
|
@@ -287,7 +353,6 @@ fn leader_start_plan_with_ambient_authority(
|
|
|
287
353
|
} else {
|
|
288
354
|
leader_env.clone()
|
|
289
355
|
};
|
|
290
|
-
let provider_argv = provider_command_argv(provider, provider_args);
|
|
291
356
|
Ok(LeaderStartPlan {
|
|
292
357
|
mode,
|
|
293
358
|
provider,
|
|
@@ -297,13 +362,9 @@ fn leader_start_plan_with_ambient_authority(
|
|
|
297
362
|
argv,
|
|
298
363
|
provider_argv,
|
|
299
364
|
// 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))),
|
|
365
|
+
// named after the provider wire (e.g. `claude_code`), never the
|
|
366
|
+
// literal string `leader`. Occupied names get a per-launch suffix.
|
|
367
|
+
leader_window: managed_window,
|
|
307
368
|
is_external_leader: external_path,
|
|
308
369
|
leader_env: plan_env,
|
|
309
370
|
identity: Some(identity),
|
|
@@ -398,7 +459,7 @@ fn execute_leader_plan_after_ambient_authority(
|
|
|
398
459
|
} else if plan.is_external_leader {
|
|
399
460
|
persist_external_leader_topology_marker(plan, workspace)?;
|
|
400
461
|
}
|
|
401
|
-
let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
|
|
462
|
+
let process = run_leader_argv(&argv, &plan.leader_env, plan, workspace, None)?;
|
|
402
463
|
let code = process.status.code();
|
|
403
464
|
if !process.status.success() {
|
|
404
465
|
return Err(LeaderError::Start(leader_launcher_failure(&process)));
|
|
@@ -899,32 +960,87 @@ fn managed_leader_session_from_candidates(
|
|
|
899
960
|
.unwrap_or_else(|| managed_leader_session_name(provider, workspace))
|
|
900
961
|
}
|
|
901
962
|
|
|
963
|
+
/// Base window is `provider_wire` (`claude_code`). A second live leader in
|
|
964
|
+
/// the same session cannot reuse that name: tmux then fails
|
|
965
|
+
/// `attach-session -t SESSION:claude_code` with `can't find window`.
|
|
966
|
+
fn managed_leader_window_from_occupied(
|
|
967
|
+
base: &str,
|
|
968
|
+
occupied: impl IntoIterator<Item = String>,
|
|
969
|
+
nonce: &str,
|
|
970
|
+
) -> WindowName {
|
|
971
|
+
let occupied = occupied
|
|
972
|
+
.into_iter()
|
|
973
|
+
.collect::<std::collections::BTreeSet<_>>();
|
|
974
|
+
if !occupied.contains(base) {
|
|
975
|
+
return WindowName::new(base);
|
|
976
|
+
}
|
|
977
|
+
let mut candidate = format!("{base}-{nonce}");
|
|
978
|
+
let mut n = 2u32;
|
|
979
|
+
while occupied.contains(&candidate) {
|
|
980
|
+
candidate = format!("{base}-{nonce}-{n}");
|
|
981
|
+
n = n.saturating_add(1);
|
|
982
|
+
if n > 32 {
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
WindowName::new(candidate)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
fn managed_leader_window_for_launch(
|
|
990
|
+
provider: Provider,
|
|
991
|
+
workspace: &Path,
|
|
992
|
+
session: &SessionName,
|
|
993
|
+
) -> WindowName {
|
|
994
|
+
let base = provider_wire(provider);
|
|
995
|
+
let transport = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
996
|
+
let session_exists = transport.has_session(session).unwrap_or(false);
|
|
997
|
+
if !session_exists {
|
|
998
|
+
return WindowName::new(base);
|
|
999
|
+
}
|
|
1000
|
+
let occupied = match transport.list_windows(session) {
|
|
1001
|
+
Ok(windows) => windows
|
|
1002
|
+
.into_iter()
|
|
1003
|
+
.map(|window| window.as_str().to_string())
|
|
1004
|
+
.collect::<Vec<_>>(),
|
|
1005
|
+
// Cannot prove the base name is free: suffix rather than collide.
|
|
1006
|
+
Err(_) => vec![base.to_string()],
|
|
1007
|
+
};
|
|
1008
|
+
let pid = std::process::id();
|
|
1009
|
+
let epoch_nanos = std::time::SystemTime::now()
|
|
1010
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
1011
|
+
.map(|d| d.as_nanos())
|
|
1012
|
+
.unwrap_or(0);
|
|
1013
|
+
let nonce = format!("{pid:x}-{epoch_nanos:x}");
|
|
1014
|
+
managed_leader_window_from_occupied(base, occupied, &nonce)
|
|
1015
|
+
}
|
|
1016
|
+
|
|
902
1017
|
fn start_argv(
|
|
903
1018
|
mode: LeaderStartMode,
|
|
904
1019
|
provider: Provider,
|
|
905
|
-
|
|
1020
|
+
provider_argv: &[String],
|
|
906
1021
|
workspace: &Path,
|
|
907
1022
|
session_name: Option<&SessionName>,
|
|
1023
|
+
leader_window: Option<&WindowName>,
|
|
908
1024
|
leader_env: &BTreeMap<String, String>,
|
|
909
1025
|
managed_client_attach_mode: Option<ManagedClientAttachMode>,
|
|
910
1026
|
) -> Result<Vec<String>, LeaderError> {
|
|
911
|
-
let provider_cmd = provider_command_name(provider).to_string();
|
|
912
1027
|
match mode {
|
|
913
|
-
LeaderStartMode::ExecProvider =>
|
|
914
|
-
let mut argv = vec![provider_cmd];
|
|
915
|
-
argv.extend(normalized_provider_args(provider_args));
|
|
916
|
-
Ok(argv)
|
|
917
|
-
}
|
|
1028
|
+
LeaderStartMode::ExecProvider => Ok(provider_argv.to_vec()),
|
|
918
1029
|
LeaderStartMode::ManagedTmuxClient => {
|
|
919
1030
|
let Some(session) = session_name else {
|
|
920
1031
|
return Err(LeaderError::Start(
|
|
921
1032
|
"managed leader session missing".to_string(),
|
|
922
1033
|
));
|
|
923
1034
|
};
|
|
1035
|
+
let Some(window) = leader_window else {
|
|
1036
|
+
return Err(LeaderError::Start(
|
|
1037
|
+
"managed leader window missing".to_string(),
|
|
1038
|
+
));
|
|
1039
|
+
};
|
|
924
1040
|
let attach_mode = managed_client_attach_mode.ok_or_else(|| {
|
|
925
1041
|
LeaderError::Start("managed client attach mode missing".to_string())
|
|
926
1042
|
})?;
|
|
927
|
-
managed_client_argv(workspace, session,
|
|
1043
|
+
managed_client_argv(workspace, session, window, attach_mode)
|
|
928
1044
|
}
|
|
929
1045
|
LeaderStartMode::AttachExisting => {
|
|
930
1046
|
let Some(session) = session_name else {
|
|
@@ -947,13 +1063,11 @@ fn start_argv(
|
|
|
947
1063
|
if let Some(path) = std::env::var_os("PATH").and_then(|p| p.into_string().ok()) {
|
|
948
1064
|
exports.push(shlex_quote(&format!("PATH={path}")));
|
|
949
1065
|
}
|
|
950
|
-
let mut provider_argv = vec![provider_cmd];
|
|
951
|
-
provider_argv.extend(normalized_provider_args(provider_args));
|
|
952
1066
|
let shell = format!(
|
|
953
1067
|
"cd {} && export {} && exec {}",
|
|
954
1068
|
shlex_quote(&resolved_workspace.to_string_lossy()),
|
|
955
1069
|
exports.join(" "),
|
|
956
|
-
shell_join(
|
|
1070
|
+
shell_join(provider_argv)
|
|
957
1071
|
);
|
|
958
1072
|
let argv = vec![
|
|
959
1073
|
"tmux".to_string(),
|
|
@@ -996,13 +1110,13 @@ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = St
|
|
|
996
1110
|
fn managed_client_argv(
|
|
997
1111
|
workspace: &Path,
|
|
998
1112
|
session: &SessionName,
|
|
999
|
-
|
|
1113
|
+
window: &WindowName,
|
|
1000
1114
|
attach_mode: ManagedClientAttachMode,
|
|
1001
1115
|
) -> Result<Vec<String>, LeaderError> {
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
//
|
|
1005
|
-
let target = format!("{}:{}", session.as_str(),
|
|
1116
|
+
// Attach to the launch's actual window (base `provider_wire` or a unique
|
|
1117
|
+
// suffix). Targeting only `provider_wire` collides when two live leaders
|
|
1118
|
+
// share the A-22 session (`can't find window: claude_code`).
|
|
1119
|
+
let target = format!("{}:{}", session.as_str(), window.as_str());
|
|
1006
1120
|
let argv = match attach_mode {
|
|
1007
1121
|
ManagedClientAttachMode::SwitchClient => vec![
|
|
1008
1122
|
"tmux".to_string(),
|
|
@@ -1085,7 +1199,20 @@ fn execute_managed_leader_plan_attempt(
|
|
|
1085
1199
|
workspace.to_path_buf(),
|
|
1086
1200
|
spawned.pane_id.as_str().to_string(),
|
|
1087
1201
|
);
|
|
1088
|
-
let process = match run_leader_argv(
|
|
1202
|
+
let process = match run_leader_argv(
|
|
1203
|
+
&plan.argv,
|
|
1204
|
+
&BTreeMap::new(),
|
|
1205
|
+
plan,
|
|
1206
|
+
workspace,
|
|
1207
|
+
Some(ManagedAttachWatch {
|
|
1208
|
+
transport: &transport,
|
|
1209
|
+
spawned: &spawned,
|
|
1210
|
+
session,
|
|
1211
|
+
session_existed_before,
|
|
1212
|
+
workspace,
|
|
1213
|
+
provider_label: provider_command_name(plan.provider),
|
|
1214
|
+
}),
|
|
1215
|
+
) {
|
|
1089
1216
|
Ok(process) => process,
|
|
1090
1217
|
Err(error) => {
|
|
1091
1218
|
cleanup_managed_leader_resources(
|
|
@@ -1099,6 +1226,14 @@ fn execute_managed_leader_plan_attempt(
|
|
|
1099
1226
|
return Err(error);
|
|
1100
1227
|
}
|
|
1101
1228
|
};
|
|
1229
|
+
if process.intentional_teardown {
|
|
1230
|
+
return Ok(LeaderLaunchOutcome {
|
|
1231
|
+
status: LeaderLaunchStatus::Exited,
|
|
1232
|
+
exit_code: Some(0),
|
|
1233
|
+
session_name: plan.session_name.clone(),
|
|
1234
|
+
reason: None,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1102
1237
|
let code = process.status.code();
|
|
1103
1238
|
if !process.status.success() {
|
|
1104
1239
|
cleanup_managed_leader_resources(
|
|
@@ -1141,14 +1276,16 @@ fn managed_leader_attach_recovery_plan(
|
|
|
1141
1276
|
workspace: &Path,
|
|
1142
1277
|
) -> Result<LeaderStartPlan, LeaderError> {
|
|
1143
1278
|
let session = managed_leader_session_for_launch(plan.provider, workspace);
|
|
1279
|
+
let window = managed_leader_window_for_launch(plan.provider, workspace, &session);
|
|
1144
1280
|
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1145
1281
|
ManagedClientAttachMode::SwitchClient
|
|
1146
1282
|
} else {
|
|
1147
1283
|
ManagedClientAttachMode::AttachSession
|
|
1148
1284
|
};
|
|
1149
|
-
let argv = managed_client_argv(workspace, &session,
|
|
1285
|
+
let argv = managed_client_argv(workspace, &session, &window, attach_mode)?;
|
|
1150
1286
|
let mut retry_plan = plan.clone();
|
|
1151
1287
|
retry_plan.session_name = Some(session);
|
|
1288
|
+
retry_plan.leader_window = Some(window);
|
|
1152
1289
|
retry_plan.argv = argv;
|
|
1153
1290
|
Ok(retry_plan)
|
|
1154
1291
|
}
|
|
@@ -1200,6 +1337,222 @@ fn ensure_managed_leader_pane(
|
|
|
1200
1337
|
}
|
|
1201
1338
|
}
|
|
1202
1339
|
|
|
1340
|
+
struct ManagedAttachWatch<'a> {
|
|
1341
|
+
transport: &'a dyn Transport,
|
|
1342
|
+
spawned: &'a SpawnResult,
|
|
1343
|
+
session: &'a SessionName,
|
|
1344
|
+
session_existed_before: bool,
|
|
1345
|
+
workspace: &'a Path,
|
|
1346
|
+
provider_label: &'a str,
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
#[cfg(unix)]
|
|
1350
|
+
static MANAGED_ATTACH_TEARDOWN: AtomicBool = AtomicBool::new(false);
|
|
1351
|
+
|
|
1352
|
+
#[cfg(unix)]
|
|
1353
|
+
struct ManagedTermGuard {
|
|
1354
|
+
old: libc::sighandler_t,
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
#[cfg(unix)]
|
|
1358
|
+
impl ManagedTermGuard {
|
|
1359
|
+
fn install() -> Self {
|
|
1360
|
+
MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
|
|
1361
|
+
let handler = managed_term_handler as *const () as libc::sighandler_t;
|
|
1362
|
+
let old = unsafe { libc::signal(libc::SIGTERM, handler) };
|
|
1363
|
+
Self { old }
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
#[cfg(unix)]
|
|
1368
|
+
impl Drop for ManagedTermGuard {
|
|
1369
|
+
fn drop(&mut self) {
|
|
1370
|
+
unsafe {
|
|
1371
|
+
libc::signal(libc::SIGTERM, self.old);
|
|
1372
|
+
}
|
|
1373
|
+
MANAGED_ATTACH_TEARDOWN.store(false, Ordering::SeqCst);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
#[cfg(unix)]
|
|
1378
|
+
extern "C" fn managed_term_handler(_: libc::c_int) {
|
|
1379
|
+
MANAGED_ATTACH_TEARDOWN.store(true, Ordering::SeqCst);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
fn managed_attach_should_teardown(watch: &ManagedAttachWatch<'_>) -> bool {
|
|
1383
|
+
let marker = crate::tmux_backend::leader_provider_exit_marker(watch.provider_label);
|
|
1384
|
+
let target = Target::Pane(watch.spawned.pane_id.clone());
|
|
1385
|
+
if let Ok(cap) = watch.transport.capture(&target, CaptureRange::Tail(80)) {
|
|
1386
|
+
if cap.text.contains(&marker) || cap.text.contains("[team-agent] Provider exited") {
|
|
1387
|
+
return true;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
matches!(
|
|
1391
|
+
leader_provider_health(
|
|
1392
|
+
watch.transport,
|
|
1393
|
+
&watch.spawned.pane_id,
|
|
1394
|
+
watch.provider_label
|
|
1395
|
+
),
|
|
1396
|
+
LeaderProviderHealth::ProviderExited | LeaderProviderHealth::Unreachable
|
|
1397
|
+
)
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
fn bound_pane_snapshot(
|
|
1401
|
+
pane_id: &str,
|
|
1402
|
+
session_name: &serde_json::Value,
|
|
1403
|
+
window_name: &serde_json::Value,
|
|
1404
|
+
tmux_socket: &serde_json::Value,
|
|
1405
|
+
) -> serde_json::Value {
|
|
1406
|
+
serde_json::json!({
|
|
1407
|
+
"pane_id": pane_id,
|
|
1408
|
+
"session_name": session_name,
|
|
1409
|
+
"window_name": window_name,
|
|
1410
|
+
"tmux_socket": tmux_socket,
|
|
1411
|
+
})
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
fn merge_bound_panes(
|
|
1415
|
+
existing: Option<&serde_json::Value>,
|
|
1416
|
+
new_pane: serde_json::Value,
|
|
1417
|
+
) -> Vec<serde_json::Value> {
|
|
1418
|
+
let new_id = new_pane
|
|
1419
|
+
.get("pane_id")
|
|
1420
|
+
.and_then(serde_json::Value::as_str)
|
|
1421
|
+
.unwrap_or("")
|
|
1422
|
+
.to_string();
|
|
1423
|
+
let mut panes = Vec::new();
|
|
1424
|
+
if let Some(existing) = existing {
|
|
1425
|
+
if let Some(arr) = existing
|
|
1426
|
+
.get("bound_panes")
|
|
1427
|
+
.and_then(serde_json::Value::as_array)
|
|
1428
|
+
{
|
|
1429
|
+
panes.extend(arr.iter().cloned());
|
|
1430
|
+
} else if let Some(old_pane) = existing.get("pane_id").and_then(serde_json::Value::as_str) {
|
|
1431
|
+
panes.push(bound_pane_snapshot(
|
|
1432
|
+
old_pane,
|
|
1433
|
+
existing
|
|
1434
|
+
.get("session_name")
|
|
1435
|
+
.unwrap_or(&serde_json::Value::Null),
|
|
1436
|
+
existing
|
|
1437
|
+
.get("window_name")
|
|
1438
|
+
.unwrap_or(&serde_json::Value::Null),
|
|
1439
|
+
existing
|
|
1440
|
+
.get("tmux_socket")
|
|
1441
|
+
.unwrap_or(&serde_json::Value::Null),
|
|
1442
|
+
));
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if !new_id.is_empty()
|
|
1446
|
+
&& !panes.iter().any(|pane| {
|
|
1447
|
+
pane.get("pane_id").and_then(serde_json::Value::as_str) == Some(new_id.as_str())
|
|
1448
|
+
})
|
|
1449
|
+
{
|
|
1450
|
+
panes.push(new_pane);
|
|
1451
|
+
}
|
|
1452
|
+
panes
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
fn apply_clear_to_leader_receiver(
|
|
1456
|
+
recv: &mut serde_json::Map<String, serde_json::Value>,
|
|
1457
|
+
pane_id: &str,
|
|
1458
|
+
) -> bool {
|
|
1459
|
+
let mut changed = false;
|
|
1460
|
+
if let Some(arr) = recv
|
|
1461
|
+
.get_mut("bound_panes")
|
|
1462
|
+
.and_then(serde_json::Value::as_array_mut)
|
|
1463
|
+
{
|
|
1464
|
+
let before = arr.len();
|
|
1465
|
+
arr.retain(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str) != Some(pane_id));
|
|
1466
|
+
if arr.len() != before {
|
|
1467
|
+
changed = true;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
let scalar_match = recv.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id);
|
|
1471
|
+
if scalar_match {
|
|
1472
|
+
let promoted = recv
|
|
1473
|
+
.get("bound_panes")
|
|
1474
|
+
.and_then(serde_json::Value::as_array)
|
|
1475
|
+
.and_then(|arr| arr.first())
|
|
1476
|
+
.cloned();
|
|
1477
|
+
if let Some(next) = promoted {
|
|
1478
|
+
recv.insert("status".to_string(), serde_json::json!("attached"));
|
|
1479
|
+
recv.insert(
|
|
1480
|
+
"pane_id".to_string(),
|
|
1481
|
+
next.get("pane_id")
|
|
1482
|
+
.cloned()
|
|
1483
|
+
.unwrap_or(serde_json::Value::Null),
|
|
1484
|
+
);
|
|
1485
|
+
recv.insert(
|
|
1486
|
+
"pane".to_string(),
|
|
1487
|
+
next.get("pane_id")
|
|
1488
|
+
.cloned()
|
|
1489
|
+
.unwrap_or(serde_json::Value::Null),
|
|
1490
|
+
);
|
|
1491
|
+
if let Some(session) = next.get("session_name") {
|
|
1492
|
+
recv.insert("session_name".to_string(), session.clone());
|
|
1493
|
+
}
|
|
1494
|
+
if let Some(window) = next.get("window_name") {
|
|
1495
|
+
recv.insert("window_name".to_string(), window.clone());
|
|
1496
|
+
}
|
|
1497
|
+
} else {
|
|
1498
|
+
recv.insert("status".to_string(), serde_json::json!("exited"));
|
|
1499
|
+
recv.remove("pane_id");
|
|
1500
|
+
recv.remove("pane");
|
|
1501
|
+
}
|
|
1502
|
+
changed = true;
|
|
1503
|
+
}
|
|
1504
|
+
changed
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
fn clear_managed_leader_receiver_pane(workspace: &Path, pane_id: &str) {
|
|
1508
|
+
let Ok(mut state) = crate::state::persist::load_runtime_state(workspace) else {
|
|
1509
|
+
return;
|
|
1510
|
+
};
|
|
1511
|
+
let mut changed = false;
|
|
1512
|
+
if let Some(teams) = state
|
|
1513
|
+
.get_mut("teams")
|
|
1514
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1515
|
+
{
|
|
1516
|
+
for (_key, team) in teams.iter_mut() {
|
|
1517
|
+
let Some(team_obj) = team.as_object_mut() else {
|
|
1518
|
+
continue;
|
|
1519
|
+
};
|
|
1520
|
+
if let Some(recv) = team_obj
|
|
1521
|
+
.get_mut("leader_receiver")
|
|
1522
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1523
|
+
{
|
|
1524
|
+
if apply_clear_to_leader_receiver(recv, pane_id) {
|
|
1525
|
+
changed = true;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if let Some(owner) = team_obj
|
|
1529
|
+
.get_mut("team_owner")
|
|
1530
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
1531
|
+
{
|
|
1532
|
+
if owner.get("pane_id").and_then(serde_json::Value::as_str) == Some(pane_id) {
|
|
1533
|
+
owner.remove("pane_id");
|
|
1534
|
+
changed = true;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
if !changed {
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
let team_key = state
|
|
1543
|
+
.get("active_team_key")
|
|
1544
|
+
.and_then(serde_json::Value::as_str)
|
|
1545
|
+
.unwrap_or("current")
|
|
1546
|
+
.to_string();
|
|
1547
|
+
let _ = crate::state::repository::StateRepository::new(workspace).save(
|
|
1548
|
+
crate::state::repository::StateWriteIntent::LeaderStartBinding {
|
|
1549
|
+
team_key: &team_key,
|
|
1550
|
+
transport_kind: "managed",
|
|
1551
|
+
},
|
|
1552
|
+
&state,
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1203
1556
|
fn cleanup_managed_leader_resources(
|
|
1204
1557
|
transport: &dyn Transport,
|
|
1205
1558
|
session: &SessionName,
|
|
@@ -1208,23 +1561,36 @@ fn cleanup_managed_leader_resources(
|
|
|
1208
1561
|
workspace: &Path,
|
|
1209
1562
|
reason: &str,
|
|
1210
1563
|
) {
|
|
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
|
-
)
|
|
1564
|
+
// Own window only — never kill-server (same socket may host other teams).
|
|
1565
|
+
// Last window in the session lets tmux destroy the session itself.
|
|
1566
|
+
let window_target = Target::SessionWindow {
|
|
1567
|
+
session: spawned.session.clone(),
|
|
1568
|
+
window: spawned.window.clone(),
|
|
1227
1569
|
};
|
|
1570
|
+
let window_result = transport
|
|
1571
|
+
.kill_window(&window_target)
|
|
1572
|
+
.map(|_| "ok".to_string())
|
|
1573
|
+
.unwrap_or_else(|error| error.to_string());
|
|
1574
|
+
let mut action = "kill_window".to_string();
|
|
1575
|
+
let mut result = window_result;
|
|
1576
|
+
if !session_existed_before {
|
|
1577
|
+
match transport.list_windows(session) {
|
|
1578
|
+
Ok(windows) if windows.is_empty() => {
|
|
1579
|
+
let session_result = transport
|
|
1580
|
+
.kill_session(session)
|
|
1581
|
+
.map(|_| "ok".to_string())
|
|
1582
|
+
.unwrap_or_else(|error| error.to_string());
|
|
1583
|
+
action = "kill_window+kill_session".to_string();
|
|
1584
|
+
result = format!("{result};kill_session={session_result}");
|
|
1585
|
+
}
|
|
1586
|
+
Ok(_) => {}
|
|
1587
|
+
Err(error) => {
|
|
1588
|
+
action = "kill_window+list_windows_err".to_string();
|
|
1589
|
+
result = format!("{result};list_windows={error}");
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
clear_managed_leader_receiver_pane(workspace, spawned.pane_id.as_str());
|
|
1228
1594
|
write_leader_startup_prompt_event(
|
|
1229
1595
|
workspace,
|
|
1230
1596
|
"leader.launcher.rollback",
|
|
@@ -1411,17 +1777,12 @@ fn persist_managed_leader_binding(
|
|
|
1411
1777
|
.ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
|
|
1412
1778
|
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
1413
1779
|
.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);
|
|
1780
|
+
let team_key = identity.team_id.as_str().to_string();
|
|
1781
|
+
let existing_recv = state
|
|
1782
|
+
.get("teams")
|
|
1783
|
+
.and_then(|teams| teams.get(&team_key))
|
|
1784
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
1785
|
+
.cloned();
|
|
1425
1786
|
let now = chrono::Utc::now().to_rfc3339();
|
|
1426
1787
|
let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
|
|
1427
1788
|
.map(|path| path.to_string_lossy().to_string())
|
|
@@ -1430,27 +1791,81 @@ fn persist_managed_leader_binding(
|
|
|
1430
1791
|
let session = spawned.session.as_str().to_string();
|
|
1431
1792
|
let window = spawned.window.as_str().to_string();
|
|
1432
1793
|
let pane = spawned.pane_id.as_str().to_string();
|
|
1794
|
+
let keep_existing_scalar = existing_recv
|
|
1795
|
+
.as_ref()
|
|
1796
|
+
.and_then(|recv| recv.get("pane_id"))
|
|
1797
|
+
.and_then(serde_json::Value::as_str)
|
|
1798
|
+
.is_some_and(|old| old != pane);
|
|
1799
|
+
let current_epoch = state
|
|
1800
|
+
.get("owner_epoch")
|
|
1801
|
+
.and_then(serde_json::Value::as_u64)
|
|
1802
|
+
.or_else(|| {
|
|
1803
|
+
state
|
|
1804
|
+
.get("team_owner")
|
|
1805
|
+
.and_then(|owner| owner.get("owner_epoch"))
|
|
1806
|
+
.and_then(serde_json::Value::as_u64)
|
|
1807
|
+
})
|
|
1808
|
+
.unwrap_or(0);
|
|
1809
|
+
let owner_epoch = if keep_existing_scalar {
|
|
1810
|
+
existing_recv
|
|
1811
|
+
.as_ref()
|
|
1812
|
+
.and_then(|recv| recv.get("owner_epoch"))
|
|
1813
|
+
.and_then(serde_json::Value::as_u64)
|
|
1814
|
+
.unwrap_or(current_epoch)
|
|
1815
|
+
} else {
|
|
1816
|
+
current_epoch.saturating_add(1)
|
|
1817
|
+
};
|
|
1433
1818
|
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1434
1819
|
"switch-client"
|
|
1435
1820
|
} else {
|
|
1436
1821
|
"attach-session"
|
|
1437
1822
|
};
|
|
1823
|
+
let new_binding = bound_pane_snapshot(
|
|
1824
|
+
&pane,
|
|
1825
|
+
&serde_json::json!(session),
|
|
1826
|
+
&serde_json::json!(window),
|
|
1827
|
+
&serde_json::json!(socket),
|
|
1828
|
+
);
|
|
1829
|
+
let bound_panes = merge_bound_panes(existing_recv.as_ref(), new_binding);
|
|
1830
|
+
let (scalar_pane, scalar_session, scalar_window, scalar_attached_at) =
|
|
1831
|
+
match existing_recv.as_ref() {
|
|
1832
|
+
Some(recv) if keep_existing_scalar => (
|
|
1833
|
+
recv.get("pane_id")
|
|
1834
|
+
.and_then(serde_json::Value::as_str)
|
|
1835
|
+
.unwrap_or(pane.as_str())
|
|
1836
|
+
.to_string(),
|
|
1837
|
+
recv.get("session_name")
|
|
1838
|
+
.and_then(serde_json::Value::as_str)
|
|
1839
|
+
.unwrap_or(session.as_str())
|
|
1840
|
+
.to_string(),
|
|
1841
|
+
recv.get("window_name")
|
|
1842
|
+
.and_then(serde_json::Value::as_str)
|
|
1843
|
+
.unwrap_or(window.as_str())
|
|
1844
|
+
.to_string(),
|
|
1845
|
+
recv.get("attached_at")
|
|
1846
|
+
.and_then(serde_json::Value::as_str)
|
|
1847
|
+
.unwrap_or(now.as_str())
|
|
1848
|
+
.to_string(),
|
|
1849
|
+
),
|
|
1850
|
+
_ => (pane.clone(), session.clone(), window.clone(), now.clone()),
|
|
1851
|
+
};
|
|
1438
1852
|
let receiver = serde_json::json!({
|
|
1439
1853
|
"mode": "direct_tmux",
|
|
1440
1854
|
"status": "attached",
|
|
1441
1855
|
"provider": provider.clone(),
|
|
1442
|
-
"pane_id":
|
|
1443
|
-
"pane":
|
|
1444
|
-
"session_name":
|
|
1445
|
-
"window_name":
|
|
1856
|
+
"pane_id": scalar_pane,
|
|
1857
|
+
"pane": scalar_pane,
|
|
1858
|
+
"session_name": scalar_session,
|
|
1859
|
+
"window_name": scalar_window,
|
|
1446
1860
|
"tmux_socket": socket,
|
|
1447
1861
|
"leader_session_uuid": identity.leader_session_uuid,
|
|
1448
1862
|
"owner_epoch": owner_epoch,
|
|
1449
|
-
"attached_at":
|
|
1863
|
+
"attached_at": scalar_attached_at,
|
|
1450
1864
|
"discovery": "managed_launcher",
|
|
1865
|
+
"bound_panes": bound_panes,
|
|
1451
1866
|
});
|
|
1452
1867
|
let owner = serde_json::json!({
|
|
1453
|
-
"pane_id":
|
|
1868
|
+
"pane_id": scalar_pane,
|
|
1454
1869
|
"provider": provider.clone(),
|
|
1455
1870
|
"machine_fingerprint": identity.machine_fingerprint,
|
|
1456
1871
|
"leader_session_uuid": identity.leader_session_uuid,
|
|
@@ -1908,6 +2323,7 @@ fn run_leader_argv(
|
|
|
1908
2323
|
env: &BTreeMap<String, String>,
|
|
1909
2324
|
plan: &LeaderStartPlan,
|
|
1910
2325
|
workspace: &Path,
|
|
2326
|
+
managed_watch: Option<ManagedAttachWatch<'_>>,
|
|
1911
2327
|
) -> Result<LeaderProcessExit, LeaderError> {
|
|
1912
2328
|
let Some(program) = argv.first() else {
|
|
1913
2329
|
return Err(LeaderError::Start(
|
|
@@ -2013,7 +2429,13 @@ fn run_leader_argv(
|
|
|
2013
2429
|
if plan.mode == LeaderStartMode::ExecProvider {
|
|
2014
2430
|
spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
|
|
2015
2431
|
}
|
|
2016
|
-
|
|
2432
|
+
#[cfg(unix)]
|
|
2433
|
+
let _term_guard = managed_watch.as_ref().map(|_| ManagedTermGuard::install());
|
|
2434
|
+
let (status, intentional_teardown) = if let Some(watch) = managed_watch.as_ref() {
|
|
2435
|
+
wait_managed_attach_child(&mut child, watch)?
|
|
2436
|
+
} else {
|
|
2437
|
+
(child.wait().map_err(LeaderError::Io)?, false)
|
|
2438
|
+
};
|
|
2017
2439
|
let stdout = match stdout_reader {
|
|
2018
2440
|
Some(reader) => match reader.join() {
|
|
2019
2441
|
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
|
@@ -2050,9 +2472,88 @@ fn run_leader_argv(
|
|
|
2050
2472
|
status,
|
|
2051
2473
|
stderr,
|
|
2052
2474
|
diagnostics_path,
|
|
2475
|
+
intentional_teardown,
|
|
2053
2476
|
})
|
|
2054
2477
|
}
|
|
2055
2478
|
|
|
2479
|
+
fn wait_managed_attach_child(
|
|
2480
|
+
child: &mut std::process::Child,
|
|
2481
|
+
watch: &ManagedAttachWatch<'_>,
|
|
2482
|
+
) -> Result<(std::process::ExitStatus, bool), LeaderError> {
|
|
2483
|
+
loop {
|
|
2484
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2485
|
+
return Ok((status, false));
|
|
2486
|
+
}
|
|
2487
|
+
let signalled = {
|
|
2488
|
+
#[cfg(unix)]
|
|
2489
|
+
{
|
|
2490
|
+
MANAGED_ATTACH_TEARDOWN.load(Ordering::SeqCst)
|
|
2491
|
+
}
|
|
2492
|
+
#[cfg(not(unix))]
|
|
2493
|
+
{
|
|
2494
|
+
false
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
if signalled || managed_attach_should_teardown(watch) {
|
|
2498
|
+
// `managed_attach_should_teardown` costs a tmux round-trip, and the
|
|
2499
|
+
// attach child can lose that race: it dies BY ITSELF inside the
|
|
2500
|
+
// window (`open terminal failed: not a terminal`, or a client whose
|
|
2501
|
+
// attach was rejected). Re-check before we touch anything. A child
|
|
2502
|
+
// that already failed on its own was never torn down by us, so it
|
|
2503
|
+
// must NOT be reported as an intentional teardown — its non-zero
|
|
2504
|
+
// status has to reach the launcher failure path
|
|
2505
|
+
// (`execute_managed_leader_plan_attempt`), which is what surfaces the
|
|
2506
|
+
// error and drives attach recovery. Only the FAILING self-exit is
|
|
2507
|
+
// diverted: a clean self-exit keeps the pre-existing behaviour
|
|
2508
|
+
// (cleanup + intentional teardown + exit 0), and an explicit signal
|
|
2509
|
+
// teardown stays intentional regardless.
|
|
2510
|
+
if !signalled {
|
|
2511
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2512
|
+
if !status.success() {
|
|
2513
|
+
return Ok((status, false));
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
let reason = if signalled {
|
|
2518
|
+
"launcher_signal"
|
|
2519
|
+
} else {
|
|
2520
|
+
"provider_exited"
|
|
2521
|
+
};
|
|
2522
|
+
cleanup_managed_leader_resources(
|
|
2523
|
+
watch.transport,
|
|
2524
|
+
watch.session,
|
|
2525
|
+
watch.spawned,
|
|
2526
|
+
watch.session_existed_before,
|
|
2527
|
+
watch.workspace,
|
|
2528
|
+
reason,
|
|
2529
|
+
);
|
|
2530
|
+
let status = terminate_managed_attach_child(child)?;
|
|
2531
|
+
return Ok((status, true));
|
|
2532
|
+
}
|
|
2533
|
+
std::thread::sleep(Duration::from_millis(200));
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
fn terminate_managed_attach_child(
|
|
2538
|
+
child: &mut std::process::Child,
|
|
2539
|
+
) -> Result<std::process::ExitStatus, LeaderError> {
|
|
2540
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2541
|
+
return Ok(status);
|
|
2542
|
+
}
|
|
2543
|
+
#[cfg(unix)]
|
|
2544
|
+
unsafe {
|
|
2545
|
+
libc::kill(child.id() as i32, libc::SIGTERM);
|
|
2546
|
+
}
|
|
2547
|
+
for _ in 0..20 {
|
|
2548
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
2549
|
+
if let Some(status) = child.try_wait().map_err(LeaderError::Io)? {
|
|
2550
|
+
return Ok(status);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
let _ = child.kill();
|
|
2554
|
+
child.wait().map_err(LeaderError::Io)
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2056
2557
|
fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
|
|
2057
2558
|
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
|
|
2058
2559
|
workspace.join(".team").join("logs").join(format!(
|
|
@@ -2092,6 +2593,7 @@ struct LeaderProcessExit {
|
|
|
2092
2593
|
status: std::process::ExitStatus,
|
|
2093
2594
|
stderr: String,
|
|
2094
2595
|
diagnostics_path: PathBuf,
|
|
2596
|
+
intentional_teardown: bool,
|
|
2095
2597
|
}
|
|
2096
2598
|
|
|
2097
2599
|
fn push_bounded_stderr(captured: &mut Vec<u8>, chunk: &[u8]) {
|
|
@@ -2313,6 +2815,9 @@ fn provider_command_name(provider: Provider) -> &'static str {
|
|
|
2313
2815
|
// (leader/start.rs:192-204 派生)自动覆盖前缀保护。
|
|
2314
2816
|
Provider::Copilot => "copilot",
|
|
2315
2817
|
Provider::GeminiCli => "gemini",
|
|
2818
|
+
Provider::Grok => "grok",
|
|
2819
|
+
Provider::CursorAgent => "agent",
|
|
2820
|
+
Provider::Pi => "pi",
|
|
2316
2821
|
Provider::Fake => "fake",
|
|
2317
2822
|
}
|
|
2318
2823
|
}
|
|
@@ -2396,10 +2901,13 @@ mod tests {
|
|
|
2396
2901
|
};
|
|
2397
2902
|
|
|
2398
2903
|
use super::{
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2904
|
+
clear_managed_leader_receiver_pane, ensure_managed_provider_live_after_attach,
|
|
2905
|
+
execute_leader_plan_after_ambient_authority, format_launcher_failure,
|
|
2906
|
+
handle_exec_provider_startup_prompts, is_interactive_shell_basename,
|
|
2907
|
+
lock_down_window_rename, managed_client_argv, managed_leader_window_from_occupied,
|
|
2908
|
+
persist_managed_leader_binding, push_bounded_stderr, shlex_quote,
|
|
2909
|
+
write_window_rename_lockdown_event, ManagedClientAttachMode, VerifiedAmbientPaneAuthority,
|
|
2910
|
+
LEADER_STDERR_LIMIT,
|
|
2403
2911
|
};
|
|
2404
2912
|
|
|
2405
2913
|
struct ScriptedTransport {
|
|
@@ -2409,6 +2917,8 @@ mod tests {
|
|
|
2409
2917
|
targets: Vec<PaneInfo>,
|
|
2410
2918
|
window_options: Mutex<Vec<(String, String)>>,
|
|
2411
2919
|
window_option_error: Mutex<Option<String>>,
|
|
2920
|
+
kills: Mutex<Vec<String>>,
|
|
2921
|
+
windows: Mutex<Vec<WindowName>>,
|
|
2412
2922
|
}
|
|
2413
2923
|
|
|
2414
2924
|
impl ScriptedTransport {
|
|
@@ -2420,6 +2930,8 @@ mod tests {
|
|
|
2420
2930
|
targets: Vec::new(),
|
|
2421
2931
|
window_options: Mutex::new(Vec::new()),
|
|
2422
2932
|
window_option_error: Mutex::new(None),
|
|
2933
|
+
kills: Mutex::new(Vec::new()),
|
|
2934
|
+
windows: Mutex::new(Vec::new()),
|
|
2423
2935
|
}
|
|
2424
2936
|
}
|
|
2425
2937
|
|
|
@@ -2431,6 +2943,8 @@ mod tests {
|
|
|
2431
2943
|
targets: Vec::new(),
|
|
2432
2944
|
window_options: Mutex::new(Vec::new()),
|
|
2433
2945
|
window_option_error: Mutex::new(None),
|
|
2946
|
+
kills: Mutex::new(Vec::new()),
|
|
2947
|
+
windows: Mutex::new(Vec::new()),
|
|
2434
2948
|
}
|
|
2435
2949
|
}
|
|
2436
2950
|
|
|
@@ -2442,6 +2956,8 @@ mod tests {
|
|
|
2442
2956
|
targets,
|
|
2443
2957
|
window_options: Mutex::new(Vec::new()),
|
|
2444
2958
|
window_option_error: Mutex::new(None),
|
|
2959
|
+
kills: Mutex::new(Vec::new()),
|
|
2960
|
+
windows: Mutex::new(Vec::new()),
|
|
2445
2961
|
}
|
|
2446
2962
|
}
|
|
2447
2963
|
|
|
@@ -2455,6 +2971,8 @@ mod tests {
|
|
|
2455
2971
|
targets: Vec::new(),
|
|
2456
2972
|
window_options: Mutex::new(Vec::new()),
|
|
2457
2973
|
window_option_error: Mutex::new(Some(error.to_string())),
|
|
2974
|
+
kills: Mutex::new(Vec::new()),
|
|
2975
|
+
windows: Mutex::new(Vec::new()),
|
|
2458
2976
|
}
|
|
2459
2977
|
}
|
|
2460
2978
|
|
|
@@ -2472,6 +2990,20 @@ mod tests {
|
|
|
2472
2990
|
Err(poisoned) => poisoned.into_inner().clone(),
|
|
2473
2991
|
}
|
|
2474
2992
|
}
|
|
2993
|
+
|
|
2994
|
+
fn record_kill(&self, action: &str) {
|
|
2995
|
+
match self.kills.lock() {
|
|
2996
|
+
Ok(mut guard) => guard.push(action.to_string()),
|
|
2997
|
+
Err(poisoned) => poisoned.into_inner().push(action.to_string()),
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
fn kills(&self) -> Vec<String> {
|
|
3002
|
+
match self.kills.lock() {
|
|
3003
|
+
Ok(guard) => guard.clone(),
|
|
3004
|
+
Err(poisoned) => poisoned.into_inner().clone(),
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
2475
3007
|
}
|
|
2476
3008
|
|
|
2477
3009
|
impl Transport for ScriptedTransport {
|
|
@@ -2576,7 +3108,10 @@ mod tests {
|
|
|
2576
3108
|
}
|
|
2577
3109
|
|
|
2578
3110
|
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
2579
|
-
|
|
3111
|
+
match self.windows.lock() {
|
|
3112
|
+
Ok(guard) => Ok(guard.clone()),
|
|
3113
|
+
Err(poisoned) => Ok(poisoned.into_inner().clone()),
|
|
3114
|
+
}
|
|
2580
3115
|
}
|
|
2581
3116
|
|
|
2582
3117
|
fn set_session_env(
|
|
@@ -2616,11 +3151,22 @@ mod tests {
|
|
|
2616
3151
|
Ok(())
|
|
2617
3152
|
}
|
|
2618
3153
|
|
|
2619
|
-
fn
|
|
3154
|
+
fn kill_server(&self) -> Result<(), TransportError> {
|
|
3155
|
+
self.record_kill("kill_server");
|
|
2620
3156
|
Ok(())
|
|
2621
3157
|
}
|
|
2622
3158
|
|
|
2623
|
-
fn
|
|
3159
|
+
fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
|
|
3160
|
+
self.record_kill(&format!("kill_session:{}", session.as_str()));
|
|
3161
|
+
Ok(())
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
fn kill_window(&self, target: &Target) -> Result<(), TransportError> {
|
|
3165
|
+
self.record_kill(&format!("kill_window:{target:?}"));
|
|
3166
|
+
match self.windows.lock() {
|
|
3167
|
+
Ok(mut guard) => guard.clear(),
|
|
3168
|
+
Err(poisoned) => poisoned.into_inner().clear(),
|
|
3169
|
+
}
|
|
2624
3170
|
Ok(())
|
|
2625
3171
|
}
|
|
2626
3172
|
|
|
@@ -3130,7 +3676,10 @@ mod tests {
|
|
|
3130
3676
|
// (recorded for audit) instead of propagating an error.
|
|
3131
3677
|
let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
|
|
3132
3678
|
|
|
3133
|
-
assert_eq!(
|
|
3679
|
+
assert_eq!(
|
|
3680
|
+
result, "failed",
|
|
3681
|
+
"a set-window-option failure is audited, not fatal"
|
|
3682
|
+
);
|
|
3134
3683
|
assert!(
|
|
3135
3684
|
stderr.contains("unknown option"),
|
|
3136
3685
|
"tmux stderr must surface in the audit payload: {stderr}"
|
|
@@ -3141,16 +3690,365 @@ mod tests {
|
|
|
3141
3690
|
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
3142
3691
|
let lockdown = events
|
|
3143
3692
|
.iter()
|
|
3144
|
-
.find(|event|
|
|
3145
|
-
event["event"] == "leader.launcher.window_rename_lockdown"
|
|
3146
|
-
})
|
|
3693
|
+
.find(|event| event["event"] == "leader.launcher.window_rename_lockdown")
|
|
3147
3694
|
.expect("rename_lockdown event must be written even on failure");
|
|
3148
3695
|
assert_eq!(lockdown["result"], "failed");
|
|
3149
3696
|
assert!(
|
|
3150
|
-
lockdown["tmux_stderr"]
|
|
3697
|
+
lockdown["tmux_stderr"]
|
|
3698
|
+
.as_str()
|
|
3699
|
+
.unwrap_or_default()
|
|
3700
|
+
.contains("unknown option"),
|
|
3151
3701
|
"event must carry tmux stderr for regression triage: {lockdown}"
|
|
3152
3702
|
);
|
|
3153
3703
|
|
|
3154
3704
|
std::fs::remove_dir_all(&workspace).ok();
|
|
3155
3705
|
}
|
|
3706
|
+
|
|
3707
|
+
#[test]
|
|
3708
|
+
fn cleanup_managed_leader_kills_own_window_not_server() {
|
|
3709
|
+
let workspace =
|
|
3710
|
+
std::env::temp_dir().join(format!("ta-cleanup-own-window-{}", std::process::id()));
|
|
3711
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
3712
|
+
let spawned = managed_spawn_result();
|
|
3713
|
+
let transport = ScriptedTransport::with_liveness(PaneLiveness::Live);
|
|
3714
|
+
super::cleanup_managed_leader_resources(
|
|
3715
|
+
&transport,
|
|
3716
|
+
&spawned.session,
|
|
3717
|
+
&spawned,
|
|
3718
|
+
false,
|
|
3719
|
+
&workspace,
|
|
3720
|
+
"test_own_window",
|
|
3721
|
+
);
|
|
3722
|
+
let kills = transport.kills();
|
|
3723
|
+
assert!(
|
|
3724
|
+
kills.iter().any(|k| k.starts_with("kill_window:")),
|
|
3725
|
+
"cleanup must kill the launched window: {kills:?}"
|
|
3726
|
+
);
|
|
3727
|
+
assert!(
|
|
3728
|
+
!kills.iter().any(|k| k == "kill_server"),
|
|
3729
|
+
"cleanup must not kill-server: {kills:?}"
|
|
3730
|
+
);
|
|
3731
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
#[test]
|
|
3735
|
+
fn cleanup_managed_leader_clears_stale_pane_pointer() {
|
|
3736
|
+
let workspace =
|
|
3737
|
+
std::env::temp_dir().join(format!("ta-cleanup-pane-pointer-{}", std::process::id()));
|
|
3738
|
+
std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
|
|
3739
|
+
let state = serde_json::json!({
|
|
3740
|
+
"active_team_key": "current",
|
|
3741
|
+
"teams": {
|
|
3742
|
+
"current": {
|
|
3743
|
+
"leader_receiver": {
|
|
3744
|
+
"status": "attached",
|
|
3745
|
+
"pane_id": "%42",
|
|
3746
|
+
"pane": "%42"
|
|
3747
|
+
},
|
|
3748
|
+
"team_owner": { "pane_id": "%42" }
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
});
|
|
3752
|
+
std::fs::write(
|
|
3753
|
+
crate::state::persist::runtime_state_path(&workspace),
|
|
3754
|
+
serde_json::to_vec_pretty(&state).unwrap(),
|
|
3755
|
+
)
|
|
3756
|
+
.unwrap();
|
|
3757
|
+
super::clear_managed_leader_receiver_pane(&workspace, "%42");
|
|
3758
|
+
let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3759
|
+
let recv = &loaded["teams"]["current"]["leader_receiver"];
|
|
3760
|
+
assert_eq!(recv["status"], serde_json::json!("exited"));
|
|
3761
|
+
assert!(
|
|
3762
|
+
recv.get("pane_id").is_none(),
|
|
3763
|
+
"dangling pane_id must be gone: {recv}"
|
|
3764
|
+
);
|
|
3765
|
+
assert!(
|
|
3766
|
+
loaded["teams"]["current"]["team_owner"]
|
|
3767
|
+
.get("pane_id")
|
|
3768
|
+
.is_none(),
|
|
3769
|
+
"owner pane_id must be gone: {loaded}"
|
|
3770
|
+
);
|
|
3771
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
#[test]
|
|
3775
|
+
fn managed_attach_teardown_detects_provider_exit_marker() {
|
|
3776
|
+
let spawned = managed_spawn_result();
|
|
3777
|
+
let transport = ScriptedTransport::new(vec![
|
|
3778
|
+
"[team-agent] claude exited with 1\n[team-agent] Provider exited; this pane no longer accepts input.".to_string(),
|
|
3779
|
+
]);
|
|
3780
|
+
let watch = super::ManagedAttachWatch {
|
|
3781
|
+
transport: &transport,
|
|
3782
|
+
spawned: &spawned,
|
|
3783
|
+
session: &spawned.session,
|
|
3784
|
+
session_existed_before: false,
|
|
3785
|
+
workspace: Path::new("/tmp"),
|
|
3786
|
+
provider_label: "claude",
|
|
3787
|
+
};
|
|
3788
|
+
assert!(
|
|
3789
|
+
super::managed_attach_should_teardown(&watch),
|
|
3790
|
+
"exit marker in capture must trigger teardown"
|
|
3791
|
+
);
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
#[test]
|
|
3795
|
+
fn managed_attach_teardown_does_not_fire_on_live_provider_screen() {
|
|
3796
|
+
let spawned = managed_spawn_result();
|
|
3797
|
+
let transport = ScriptedTransport::new(vec!["Welcome to Claude Code".to_string()]);
|
|
3798
|
+
let watch = super::ManagedAttachWatch {
|
|
3799
|
+
transport: &transport,
|
|
3800
|
+
spawned: &spawned,
|
|
3801
|
+
session: &spawned.session,
|
|
3802
|
+
session_existed_before: false,
|
|
3803
|
+
workspace: Path::new("/tmp"),
|
|
3804
|
+
provider_label: "claude",
|
|
3805
|
+
};
|
|
3806
|
+
assert!(
|
|
3807
|
+
!super::managed_attach_should_teardown(&watch),
|
|
3808
|
+
"live welcome screen must not teardown"
|
|
3809
|
+
);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
#[test]
|
|
3813
|
+
fn managed_attach_wait_watches_provider_exit_grep_guard() {
|
|
3814
|
+
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
3815
|
+
let start_rs = manifest.join("src").join("leader").join("start.rs");
|
|
3816
|
+
let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
|
|
3817
|
+
let start = contents
|
|
3818
|
+
.find("fn run_leader_argv(")
|
|
3819
|
+
.expect("run_leader_argv must exist");
|
|
3820
|
+
let end = contents[start + 1..]
|
|
3821
|
+
.find("\nfn ")
|
|
3822
|
+
.map(|offset| start + 1 + offset)
|
|
3823
|
+
.unwrap_or(contents.len());
|
|
3824
|
+
let body = &contents[start..end];
|
|
3825
|
+
assert!(
|
|
3826
|
+
body.contains("wait_managed_attach_child"),
|
|
3827
|
+
"managed attach wait must watch provider lifetime; body excerpt: {body}"
|
|
3828
|
+
);
|
|
3829
|
+
let cleanup = contents
|
|
3830
|
+
.find("fn cleanup_managed_leader_resources(")
|
|
3831
|
+
.expect("cleanup_managed_leader_resources must exist");
|
|
3832
|
+
let cleanup_end = contents[cleanup + 1..]
|
|
3833
|
+
.find("\nfn ")
|
|
3834
|
+
.map(|offset| cleanup + 1 + offset)
|
|
3835
|
+
.unwrap_or(contents.len());
|
|
3836
|
+
let cleanup_body = &contents[cleanup..cleanup_end];
|
|
3837
|
+
assert!(
|
|
3838
|
+
!cleanup_body.contains("kill_server"),
|
|
3839
|
+
"cleanup must not call kill_server; body excerpt: {cleanup_body}"
|
|
3840
|
+
);
|
|
3841
|
+
assert!(
|
|
3842
|
+
cleanup_body.contains("kill_window"),
|
|
3843
|
+
"cleanup must kill own window; body excerpt: {cleanup_body}"
|
|
3844
|
+
);
|
|
3845
|
+
assert!(
|
|
3846
|
+
cleanup_body.contains("clear_managed_leader_receiver_pane"),
|
|
3847
|
+
"cleanup must clear state.json pane pointer; body excerpt: {cleanup_body}"
|
|
3848
|
+
);
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
fn persist_test_identity(workspace: &Path) -> LeaderIdentity {
|
|
3852
|
+
LeaderIdentity {
|
|
3853
|
+
leader_session_uuid: LeaderSessionUuid::derive(
|
|
3854
|
+
"fp",
|
|
3855
|
+
&workspace.to_string_lossy(),
|
|
3856
|
+
"tester",
|
|
3857
|
+
"current",
|
|
3858
|
+
)
|
|
3859
|
+
.unwrap(),
|
|
3860
|
+
leader_session_uuid_source: LeaderSessionUuidSource::Derived,
|
|
3861
|
+
machine_fingerprint: "fp".to_string(),
|
|
3862
|
+
workspace_abspath: workspace.to_path_buf(),
|
|
3863
|
+
os_user: "tester".to_string(),
|
|
3864
|
+
team_id: TeamKey::new("current"),
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
fn persist_test_plan(workspace: &Path) -> LeaderStartPlan {
|
|
3869
|
+
LeaderStartPlan {
|
|
3870
|
+
mode: LeaderStartMode::ManagedTmuxClient,
|
|
3871
|
+
provider: Provider::ClaudeCode,
|
|
3872
|
+
workspace: workspace.to_path_buf(),
|
|
3873
|
+
socket: LeaderLaunchSocket::Workspace,
|
|
3874
|
+
session_name: Some(SessionName::new("team-agent-leader-claude_code-demo")),
|
|
3875
|
+
argv: vec!["tmux".to_string(), "attach-session".to_string()],
|
|
3876
|
+
provider_argv: vec!["claude".to_string()],
|
|
3877
|
+
leader_window: Some(WindowName::new("claude_code")),
|
|
3878
|
+
is_external_leader: false,
|
|
3879
|
+
leader_env: BTreeMap::new(),
|
|
3880
|
+
identity: Some(persist_test_identity(workspace)),
|
|
3881
|
+
detached: false,
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
#[test]
|
|
3886
|
+
fn multi_leader_window_keeps_base_when_free() {
|
|
3887
|
+
let window =
|
|
3888
|
+
managed_leader_window_from_occupied("claude_code", Vec::<String>::new(), "deadbeef");
|
|
3889
|
+
assert_eq!(window.as_str(), "claude_code");
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
#[test]
|
|
3893
|
+
fn multi_leader_window_suffixes_when_taken() {
|
|
3894
|
+
let window = managed_leader_window_from_occupied(
|
|
3895
|
+
"claude_code",
|
|
3896
|
+
vec!["claude_code".to_string()],
|
|
3897
|
+
"aa-bb",
|
|
3898
|
+
);
|
|
3899
|
+
assert_eq!(window.as_str(), "claude_code-aa-bb");
|
|
3900
|
+
assert_ne!(window.as_str(), "claude_code");
|
|
3901
|
+
}
|
|
3902
|
+
|
|
3903
|
+
#[test]
|
|
3904
|
+
fn multi_leader_attach_targets_unique_window() {
|
|
3905
|
+
let session = SessionName::new("team-agent-leader-claude_code-demo");
|
|
3906
|
+
let window = WindowName::new("claude_code-aa-bb");
|
|
3907
|
+
let argv = managed_client_argv(
|
|
3908
|
+
Path::new("/tmp"),
|
|
3909
|
+
&session,
|
|
3910
|
+
&window,
|
|
3911
|
+
ManagedClientAttachMode::AttachSession,
|
|
3912
|
+
)
|
|
3913
|
+
.expect("argv");
|
|
3914
|
+
let target = argv
|
|
3915
|
+
.windows(2)
|
|
3916
|
+
.find(|pair| pair[0] == "-t")
|
|
3917
|
+
.map(|pair| pair[1].as_str())
|
|
3918
|
+
.expect("-t target");
|
|
3919
|
+
assert_eq!(
|
|
3920
|
+
target,
|
|
3921
|
+
"team-agent-leader-claude_code-demo:claude_code-aa-bb"
|
|
3922
|
+
);
|
|
3923
|
+
assert!(
|
|
3924
|
+
!target.ends_with(":claude_code"),
|
|
3925
|
+
"attach must not collapse to the colliding provider_wire name: {target}"
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
#[test]
|
|
3930
|
+
fn multi_leader_persist_keeps_first_pane_id() {
|
|
3931
|
+
let workspace =
|
|
3932
|
+
std::env::temp_dir().join(format!("ta-multi-leader-persist-{}", std::process::id()));
|
|
3933
|
+
std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
|
|
3934
|
+
let plan = persist_test_plan(&workspace);
|
|
3935
|
+
let first = SpawnResult {
|
|
3936
|
+
pane_id: PaneId::new("%0"),
|
|
3937
|
+
session: SessionName::new("team-agent-leader-claude_code-demo"),
|
|
3938
|
+
window: WindowName::new("claude_code"),
|
|
3939
|
+
child_pid: Some(1),
|
|
3940
|
+
};
|
|
3941
|
+
persist_managed_leader_binding(&plan, &workspace, &first).expect("first persist");
|
|
3942
|
+
let second = SpawnResult {
|
|
3943
|
+
pane_id: PaneId::new("%1"),
|
|
3944
|
+
session: SessionName::new("team-agent-leader-claude_code-demo"),
|
|
3945
|
+
window: WindowName::new("claude_code-aa-bb"),
|
|
3946
|
+
child_pid: Some(2),
|
|
3947
|
+
};
|
|
3948
|
+
persist_managed_leader_binding(&plan, &workspace, &second).expect("second persist");
|
|
3949
|
+
let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
3950
|
+
let recv = &loaded["teams"]["current"]["leader_receiver"];
|
|
3951
|
+
assert_eq!(
|
|
3952
|
+
recv["pane_id"],
|
|
3953
|
+
serde_json::json!("%0"),
|
|
3954
|
+
"second launch must not overwrite the first pane pointer: {recv}"
|
|
3955
|
+
);
|
|
3956
|
+
let panes = recv["bound_panes"].as_array().expect("bound_panes");
|
|
3957
|
+
let ids: Vec<&str> = panes
|
|
3958
|
+
.iter()
|
|
3959
|
+
.filter_map(|pane| pane.get("pane_id").and_then(serde_json::Value::as_str))
|
|
3960
|
+
.collect();
|
|
3961
|
+
assert!(ids.contains(&"%0"), "bound_panes missing first: {recv}");
|
|
3962
|
+
assert!(ids.contains(&"%1"), "bound_panes missing second: {recv}");
|
|
3963
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
#[test]
|
|
3967
|
+
fn multi_leader_clear_promotes_remaining() {
|
|
3968
|
+
let workspace =
|
|
3969
|
+
std::env::temp_dir().join(format!("ta-multi-leader-promote-{}", std::process::id()));
|
|
3970
|
+
std::fs::create_dir_all(workspace.join(".team").join("runtime")).unwrap();
|
|
3971
|
+
let state = serde_json::json!({
|
|
3972
|
+
"active_team_key": "current",
|
|
3973
|
+
"teams": {
|
|
3974
|
+
"current": {
|
|
3975
|
+
"leader_receiver": {
|
|
3976
|
+
"status": "attached",
|
|
3977
|
+
"pane_id": "%0",
|
|
3978
|
+
"pane": "%0",
|
|
3979
|
+
"session_name": "s",
|
|
3980
|
+
"window_name": "claude_code",
|
|
3981
|
+
"bound_panes": [
|
|
3982
|
+
{
|
|
3983
|
+
"pane_id": "%0",
|
|
3984
|
+
"session_name": "s",
|
|
3985
|
+
"window_name": "claude_code"
|
|
3986
|
+
},
|
|
3987
|
+
{
|
|
3988
|
+
"pane_id": "%1",
|
|
3989
|
+
"session_name": "s",
|
|
3990
|
+
"window_name": "claude_code-2"
|
|
3991
|
+
}
|
|
3992
|
+
]
|
|
3993
|
+
},
|
|
3994
|
+
"team_owner": { "pane_id": "%0" }
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3997
|
+
});
|
|
3998
|
+
std::fs::write(
|
|
3999
|
+
crate::state::persist::runtime_state_path(&workspace),
|
|
4000
|
+
serde_json::to_vec_pretty(&state).unwrap(),
|
|
4001
|
+
)
|
|
4002
|
+
.unwrap();
|
|
4003
|
+
clear_managed_leader_receiver_pane(&workspace, "%0");
|
|
4004
|
+
let loaded = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
4005
|
+
let recv = &loaded["teams"]["current"]["leader_receiver"];
|
|
4006
|
+
assert_eq!(recv["pane_id"], serde_json::json!("%1"));
|
|
4007
|
+
assert_eq!(recv["status"], serde_json::json!("attached"));
|
|
4008
|
+
let panes = recv["bound_panes"].as_array().expect("bound_panes");
|
|
4009
|
+
assert_eq!(panes.len(), 1);
|
|
4010
|
+
assert_eq!(panes[0]["pane_id"], serde_json::json!("%1"));
|
|
4011
|
+
assert!(
|
|
4012
|
+
loaded["teams"]["current"]["team_owner"]
|
|
4013
|
+
.get("pane_id")
|
|
4014
|
+
.is_none(),
|
|
4015
|
+
"owner pane for the exited leader must be gone: {loaded}"
|
|
4016
|
+
);
|
|
4017
|
+
let _ = std::fs::remove_dir_all(&workspace);
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
#[test]
|
|
4021
|
+
fn multi_leader_launch_window_dedup_contract() {
|
|
4022
|
+
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
4023
|
+
let start_rs = manifest.join("src").join("leader").join("start.rs");
|
|
4024
|
+
let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
|
|
4025
|
+
let start = contents
|
|
4026
|
+
.find("fn leader_start_plan_with_ambient_authority(")
|
|
4027
|
+
.expect("leader_start_plan_with_ambient_authority must exist");
|
|
4028
|
+
let end = contents[start + 1..]
|
|
4029
|
+
.find("\nfn ")
|
|
4030
|
+
.map(|offset| start + 1 + offset)
|
|
4031
|
+
.unwrap_or(contents.len());
|
|
4032
|
+
let body = &contents[start..end];
|
|
4033
|
+
assert!(
|
|
4034
|
+
body.contains("managed_leader_window_for_launch(provider, workspace, session)"),
|
|
4035
|
+
"managed launch must pick a unique window in the A-22 session; body excerpt: {body}"
|
|
4036
|
+
);
|
|
4037
|
+
let argv_start = contents
|
|
4038
|
+
.find("fn managed_client_argv(")
|
|
4039
|
+
.expect("managed_client_argv must exist");
|
|
4040
|
+
let argv_end = contents[argv_start + 1..]
|
|
4041
|
+
.find("\nfn ")
|
|
4042
|
+
.map(|offset| argv_start + 1 + offset)
|
|
4043
|
+
.unwrap_or(contents.len());
|
|
4044
|
+
let argv_body = &contents[argv_start..argv_end];
|
|
4045
|
+
assert!(
|
|
4046
|
+
argv_body.contains("window.as_str()"),
|
|
4047
|
+
"attach target must use the launch window, not a hardcoded wire name; body excerpt: {argv_body}"
|
|
4048
|
+
);
|
|
4049
|
+
assert!(
|
|
4050
|
+
!argv_body.contains("provider_wire(provider)"),
|
|
4051
|
+
"attach target must not collapse to provider_wire; body excerpt: {argv_body}"
|
|
4052
|
+
);
|
|
4053
|
+
}
|
|
3156
4054
|
}
|