@team-agent/installer 0.5.65 → 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 +6 -0
- package/crates/team-agent/src/cli/adapters.rs +6 -1
- 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/compile.rs +1 -1
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
- package/crates/team-agent/src/cli/tests/mod.rs +2 -2
- package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -1
- 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/cli/tests/verb_validate.rs +1 -1
- package/crates/team-agent/src/communication_mode/mod.rs +6 -4
- package/crates/team-agent/src/compiler/tests.rs +23 -20
- package/crates/team-agent/src/compiler.rs +84 -5
- package/crates/team-agent/src/conpty/backend.rs +16 -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 +1080 -58
- package/crates/team-agent/src/leader/tests/team_in_team_state_scope_red.rs +1 -1
- 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 -14
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +69 -2
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +196 -2
- 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 -457
- 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 +101 -4
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +244 -48
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -2
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
- package/crates/team-agent/src/lifecycle/launch.rs +209 -43
- 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 +213 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +373 -18
- 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 +165 -15
- 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/acceptance_b_batch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +3 -4
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +4 -4
- 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 +159 -174
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +15 -13
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -18
- package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +1 -1
- 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/display_adaptive_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/f032_startup_prompt_best_effort_red.rs +1 -1
- 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/harvest2_a_batch_red.rs +3 -0
- package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +65 -40
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -32
- package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +17 -9
- 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 +24 -23
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +98 -62
- package/crates/team-agent/src/lifecycle/tests/quick_start_worker_readiness_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/restart_liveness_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +3 -1
- package/crates/team-agent/src/lifecycle/tests/restart_session_capture_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/resume_recover_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +41 -8
- package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +3 -3
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/swallow_batch4_semantics_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/team_in_team_sibling_quick_start_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/team_in_team_state_scope_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/team_key_retirement_txn_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +60 -2
- package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
- package/crates/team-agent/src/lifecycle/tests.rs +23 -15
- package/crates/team-agent/src/lifecycle/types.rs +61 -2
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +200 -33
- 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/main_preserved.rs +1 -1
- 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/model/spec.rs +129 -6
- package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +3 -1
- package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +3 -1
- package/crates/team-agent/src/model/testdata/team.spec.yaml +3 -1
- 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/claude.rs +5 -2
- package/crates/team-agent/src/provider/adapters/codex.rs +5 -2
- package/crates/team-agent/src/provider/adapters/copilot.rs +5 -2
- 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 +124 -0
- 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 +13 -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 +1083 -75
- package/crates/team-agent/src/transport/test_support.rs +20 -0
- package/crates/team-agent/src/transport/tests/wire.rs +28 -2
- package/crates/team-agent/src/transport.rs +265 -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/approval.rs +0 -134
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -483
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
- package/crates/team-agent/src/lifecycle/tests/codex_mcp_approval_inheritance_red.rs +0 -1187
- package/crates/team-agent/src/lifecycle/tests/worker_dangerous_bypass_argv_red.rs +0 -327
|
@@ -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(),
|
|
@@ -1059,6 +1133,16 @@ fn execute_managed_leader_plan_attempt(
|
|
|
1059
1133
|
workspace,
|
|
1060
1134
|
session_existed_before,
|
|
1061
1135
|
)?;
|
|
1136
|
+
// A-55 (window rename race): tmux defaults `allow-rename` + `automatic-rename`
|
|
1137
|
+
// ON, so the pane's shell wrapper can print an OSC title-set escape during
|
|
1138
|
+
// init (zshrc/starship/PROMPT_COMMAND) and rename the window away from WINDOW
|
|
1139
|
+
// between spawn-poll and attach — making `attach-session -t SESSION:WINDOW`
|
|
1140
|
+
// fail with "can't find window: WINDOW". Lock both off right after spawn,
|
|
1141
|
+
// before run_leader_argv. Best-effort: a failure must NOT abort the launcher
|
|
1142
|
+
// (old tmux may lack the option), but it is audited below.
|
|
1143
|
+
let (rename_result, rename_stderr) =
|
|
1144
|
+
lock_down_window_rename(&transport, session, &spawned.window);
|
|
1145
|
+
write_window_rename_lockdown_event(workspace, &rename_result, &rename_stderr);
|
|
1062
1146
|
if let Err(error) = persist_managed_leader_binding(plan, workspace, &spawned) {
|
|
1063
1147
|
cleanup_managed_leader_resources(
|
|
1064
1148
|
&transport,
|
|
@@ -1075,7 +1159,20 @@ fn execute_managed_leader_plan_attempt(
|
|
|
1075
1159
|
workspace.to_path_buf(),
|
|
1076
1160
|
spawned.pane_id.as_str().to_string(),
|
|
1077
1161
|
);
|
|
1078
|
-
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
|
+
) {
|
|
1079
1176
|
Ok(process) => process,
|
|
1080
1177
|
Err(error) => {
|
|
1081
1178
|
cleanup_managed_leader_resources(
|
|
@@ -1089,6 +1186,14 @@ fn execute_managed_leader_plan_attempt(
|
|
|
1089
1186
|
return Err(error);
|
|
1090
1187
|
}
|
|
1091
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
|
+
}
|
|
1092
1197
|
let code = process.status.code();
|
|
1093
1198
|
if !process.status.success() {
|
|
1094
1199
|
cleanup_managed_leader_resources(
|
|
@@ -1131,14 +1236,16 @@ fn managed_leader_attach_recovery_plan(
|
|
|
1131
1236
|
workspace: &Path,
|
|
1132
1237
|
) -> Result<LeaderStartPlan, LeaderError> {
|
|
1133
1238
|
let session = managed_leader_session_for_launch(plan.provider, workspace);
|
|
1239
|
+
let window = managed_leader_window_for_launch(plan.provider, workspace, &session);
|
|
1134
1240
|
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1135
1241
|
ManagedClientAttachMode::SwitchClient
|
|
1136
1242
|
} else {
|
|
1137
1243
|
ManagedClientAttachMode::AttachSession
|
|
1138
1244
|
};
|
|
1139
|
-
let argv = managed_client_argv(workspace, &session,
|
|
1245
|
+
let argv = managed_client_argv(workspace, &session, &window, attach_mode)?;
|
|
1140
1246
|
let mut retry_plan = plan.clone();
|
|
1141
1247
|
retry_plan.session_name = Some(session);
|
|
1248
|
+
retry_plan.leader_window = Some(window);
|
|
1142
1249
|
retry_plan.argv = argv;
|
|
1143
1250
|
Ok(retry_plan)
|
|
1144
1251
|
}
|
|
@@ -1190,6 +1297,222 @@ fn ensure_managed_leader_pane(
|
|
|
1190
1297
|
}
|
|
1191
1298
|
}
|
|
1192
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
|
+
|
|
1193
1516
|
fn cleanup_managed_leader_resources(
|
|
1194
1517
|
transport: &dyn Transport,
|
|
1195
1518
|
session: &SessionName,
|
|
@@ -1198,23 +1521,36 @@ fn cleanup_managed_leader_resources(
|
|
|
1198
1521
|
workspace: &Path,
|
|
1199
1522
|
reason: &str,
|
|
1200
1523
|
) {
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
.map(|_| "ok".to_string())
|
|
1207
|
-
.unwrap_or_else(|error| error.to_string()),
|
|
1208
|
-
)
|
|
1209
|
-
} else {
|
|
1210
|
-
(
|
|
1211
|
-
"kill_session",
|
|
1212
|
-
transport
|
|
1213
|
-
.kill_session(session)
|
|
1214
|
-
.map(|_| "ok".to_string())
|
|
1215
|
-
.unwrap_or_else(|error| error.to_string()),
|
|
1216
|
-
)
|
|
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(),
|
|
1217
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());
|
|
1218
1554
|
write_leader_startup_prompt_event(
|
|
1219
1555
|
workspace,
|
|
1220
1556
|
"leader.launcher.rollback",
|
|
@@ -1401,17 +1737,12 @@ fn persist_managed_leader_binding(
|
|
|
1401
1737
|
.ok_or_else(|| LeaderError::Start("managed leader identity missing".to_string()))?;
|
|
1402
1738
|
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
1403
1739
|
.unwrap_or_else(|_| serde_json::json!({}));
|
|
1404
|
-
let
|
|
1405
|
-
|
|
1406
|
-
.
|
|
1407
|
-
.
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
.and_then(|owner| owner.get("owner_epoch"))
|
|
1411
|
-
.and_then(serde_json::Value::as_u64)
|
|
1412
|
-
})
|
|
1413
|
-
.unwrap_or(0)
|
|
1414
|
-
.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();
|
|
1415
1746
|
let now = chrono::Utc::now().to_rfc3339();
|
|
1416
1747
|
let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
|
|
1417
1748
|
.map(|path| path.to_string_lossy().to_string())
|
|
@@ -1420,27 +1751,81 @@ fn persist_managed_leader_binding(
|
|
|
1420
1751
|
let session = spawned.session.as_str().to_string();
|
|
1421
1752
|
let window = spawned.window.as_str().to_string();
|
|
1422
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
|
+
};
|
|
1423
1778
|
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1424
1779
|
"switch-client"
|
|
1425
1780
|
} else {
|
|
1426
1781
|
"attach-session"
|
|
1427
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
|
+
};
|
|
1428
1812
|
let receiver = serde_json::json!({
|
|
1429
1813
|
"mode": "direct_tmux",
|
|
1430
1814
|
"status": "attached",
|
|
1431
1815
|
"provider": provider.clone(),
|
|
1432
|
-
"pane_id":
|
|
1433
|
-
"pane":
|
|
1434
|
-
"session_name":
|
|
1435
|
-
"window_name":
|
|
1816
|
+
"pane_id": scalar_pane,
|
|
1817
|
+
"pane": scalar_pane,
|
|
1818
|
+
"session_name": scalar_session,
|
|
1819
|
+
"window_name": scalar_window,
|
|
1436
1820
|
"tmux_socket": socket,
|
|
1437
1821
|
"leader_session_uuid": identity.leader_session_uuid,
|
|
1438
1822
|
"owner_epoch": owner_epoch,
|
|
1439
|
-
"attached_at":
|
|
1823
|
+
"attached_at": scalar_attached_at,
|
|
1440
1824
|
"discovery": "managed_launcher",
|
|
1825
|
+
"bound_panes": bound_panes,
|
|
1441
1826
|
});
|
|
1442
1827
|
let owner = serde_json::json!({
|
|
1443
|
-
"pane_id":
|
|
1828
|
+
"pane_id": scalar_pane,
|
|
1444
1829
|
"provider": provider.clone(),
|
|
1445
1830
|
"machine_fingerprint": identity.machine_fingerprint,
|
|
1446
1831
|
"leader_session_uuid": identity.leader_session_uuid,
|
|
@@ -1898,6 +2283,7 @@ fn run_leader_argv(
|
|
|
1898
2283
|
env: &BTreeMap<String, String>,
|
|
1899
2284
|
plan: &LeaderStartPlan,
|
|
1900
2285
|
workspace: &Path,
|
|
2286
|
+
managed_watch: Option<ManagedAttachWatch<'_>>,
|
|
1901
2287
|
) -> Result<LeaderProcessExit, LeaderError> {
|
|
1902
2288
|
let Some(program) = argv.first() else {
|
|
1903
2289
|
return Err(LeaderError::Start(
|
|
@@ -2003,7 +2389,13 @@ fn run_leader_argv(
|
|
|
2003
2389
|
if plan.mode == LeaderStartMode::ExecProvider {
|
|
2004
2390
|
spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
|
|
2005
2391
|
}
|
|
2006
|
-
|
|
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
|
+
};
|
|
2007
2399
|
let stdout = match stdout_reader {
|
|
2008
2400
|
Some(reader) => match reader.join() {
|
|
2009
2401
|
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
|
@@ -2040,9 +2432,88 @@ fn run_leader_argv(
|
|
|
2040
2432
|
status,
|
|
2041
2433
|
stderr,
|
|
2042
2434
|
diagnostics_path,
|
|
2435
|
+
intentional_teardown,
|
|
2043
2436
|
})
|
|
2044
2437
|
}
|
|
2045
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
|
+
|
|
2046
2517
|
fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
|
|
2047
2518
|
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
|
|
2048
2519
|
workspace.join(".team").join("logs").join(format!(
|
|
@@ -2082,6 +2553,7 @@ struct LeaderProcessExit {
|
|
|
2082
2553
|
status: std::process::ExitStatus,
|
|
2083
2554
|
stderr: String,
|
|
2084
2555
|
diagnostics_path: PathBuf,
|
|
2556
|
+
intentional_teardown: bool,
|
|
2085
2557
|
}
|
|
2086
2558
|
|
|
2087
2559
|
fn push_bounded_stderr(captured: &mut Vec<u8>, chunk: &[u8]) {
|
|
@@ -2246,6 +2718,44 @@ fn write_leader_startup_prompt_event(workspace: &Path, event: &str, fields: serd
|
|
|
2246
2718
|
let _ = crate::event_log::EventLog::new(workspace).write(event, fields);
|
|
2247
2719
|
}
|
|
2248
2720
|
|
|
2721
|
+
/// A-55 (window rename race): lock the freshly-spawned leader window against
|
|
2722
|
+
/// rename — `allow-rename off` (refuse pane-internal OSC title-set renames)
|
|
2723
|
+
/// AND `automatic-rename off` (refuse tmux's own command-driven rename). Both
|
|
2724
|
+
/// must be set; tmux treats them as independent. Best-effort: a failure must
|
|
2725
|
+
/// NOT abort the launcher (old tmux may lack the option) — return the outcome
|
|
2726
|
+
/// for audit instead of propagating an error. Returns (result, tmux_stderr).
|
|
2727
|
+
fn lock_down_window_rename(
|
|
2728
|
+
transport: &dyn Transport,
|
|
2729
|
+
session: &SessionName,
|
|
2730
|
+
window: &WindowName,
|
|
2731
|
+
) -> (String, String) {
|
|
2732
|
+
let allow_rename = transport.set_window_option(session, window, "allow-rename", "off");
|
|
2733
|
+
let automatic_rename = transport.set_window_option(session, window, "automatic-rename", "off");
|
|
2734
|
+
let (result, stderr) = match (&allow_rename, &automatic_rename) {
|
|
2735
|
+
(Ok(()), Ok(())) => ("ok".to_string(), String::new()),
|
|
2736
|
+
(Ok(()), Err(error)) => ("failed".to_string(), error.to_string()),
|
|
2737
|
+
(Err(error), Ok(())) => ("failed".to_string(), error.to_string()),
|
|
2738
|
+
(Err(a), Err(b)) => (
|
|
2739
|
+
"failed".to_string(),
|
|
2740
|
+
format!("allow-rename: {a}; automatic-rename: {b}"),
|
|
2741
|
+
),
|
|
2742
|
+
};
|
|
2743
|
+
(result, stderr)
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
/// A-55: audit the rename-lockdown outcome to the event log. Failure does not
|
|
2747
|
+
/// abort the launcher; record ok/failed + tmux stderr for regression triage.
|
|
2748
|
+
fn write_window_rename_lockdown_event(workspace: &Path, result: &str, stderr: &str) {
|
|
2749
|
+
write_leader_startup_prompt_event(
|
|
2750
|
+
workspace,
|
|
2751
|
+
"leader.launcher.window_rename_lockdown",
|
|
2752
|
+
serde_json::json!({
|
|
2753
|
+
"result": result,
|
|
2754
|
+
"tmux_stderr": stderr,
|
|
2755
|
+
}),
|
|
2756
|
+
);
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2249
2759
|
fn ensure_tmux_installed() -> Result<(), LeaderError> {
|
|
2250
2760
|
match Command::new("tmux").arg("-V").output() {
|
|
2251
2761
|
Ok(output) if output.status.success() => Ok(()),
|
|
@@ -2265,6 +2775,8 @@ fn provider_command_name(provider: Provider) -> &'static str {
|
|
|
2265
2775
|
// (leader/start.rs:192-204 派生)自动覆盖前缀保护。
|
|
2266
2776
|
Provider::Copilot => "copilot",
|
|
2267
2777
|
Provider::GeminiCli => "gemini",
|
|
2778
|
+
Provider::Grok => "grok",
|
|
2779
|
+
Provider::CursorAgent => "agent",
|
|
2268
2780
|
Provider::Fake => "fake",
|
|
2269
2781
|
}
|
|
2270
2782
|
}
|
|
@@ -2348,10 +2860,13 @@ mod tests {
|
|
|
2348
2860
|
};
|
|
2349
2861
|
|
|
2350
2862
|
use super::{
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
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,
|
|
2355
2870
|
};
|
|
2356
2871
|
|
|
2357
2872
|
struct ScriptedTransport {
|
|
@@ -2359,6 +2874,10 @@ mod tests {
|
|
|
2359
2874
|
sent: Mutex<Vec<(Target, Vec<Key>)>>,
|
|
2360
2875
|
liveness: PaneLiveness,
|
|
2361
2876
|
targets: Vec<PaneInfo>,
|
|
2877
|
+
window_options: Mutex<Vec<(String, String)>>,
|
|
2878
|
+
window_option_error: Mutex<Option<String>>,
|
|
2879
|
+
kills: Mutex<Vec<String>>,
|
|
2880
|
+
windows: Mutex<Vec<WindowName>>,
|
|
2362
2881
|
}
|
|
2363
2882
|
|
|
2364
2883
|
impl ScriptedTransport {
|
|
@@ -2368,6 +2887,10 @@ mod tests {
|
|
|
2368
2887
|
sent: Mutex::new(Vec::new()),
|
|
2369
2888
|
liveness: PaneLiveness::Unknown,
|
|
2370
2889
|
targets: Vec::new(),
|
|
2890
|
+
window_options: Mutex::new(Vec::new()),
|
|
2891
|
+
window_option_error: Mutex::new(None),
|
|
2892
|
+
kills: Mutex::new(Vec::new()),
|
|
2893
|
+
windows: Mutex::new(Vec::new()),
|
|
2371
2894
|
}
|
|
2372
2895
|
}
|
|
2373
2896
|
|
|
@@ -2377,6 +2900,10 @@ mod tests {
|
|
|
2377
2900
|
sent: Mutex::new(Vec::new()),
|
|
2378
2901
|
liveness,
|
|
2379
2902
|
targets: Vec::new(),
|
|
2903
|
+
window_options: Mutex::new(Vec::new()),
|
|
2904
|
+
window_option_error: Mutex::new(None),
|
|
2905
|
+
kills: Mutex::new(Vec::new()),
|
|
2906
|
+
windows: Mutex::new(Vec::new()),
|
|
2380
2907
|
}
|
|
2381
2908
|
}
|
|
2382
2909
|
|
|
@@ -2386,6 +2913,25 @@ mod tests {
|
|
|
2386
2913
|
sent: Mutex::new(Vec::new()),
|
|
2387
2914
|
liveness,
|
|
2388
2915
|
targets,
|
|
2916
|
+
window_options: Mutex::new(Vec::new()),
|
|
2917
|
+
window_option_error: Mutex::new(None),
|
|
2918
|
+
kills: Mutex::new(Vec::new()),
|
|
2919
|
+
windows: Mutex::new(Vec::new()),
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/// A-55: a ScriptedTransport whose `set_window_option` always fails with
|
|
2924
|
+
/// the given tmux stderr — proves the lockdown never aborts the launcher.
|
|
2925
|
+
fn with_window_option_error(error: &str) -> Self {
|
|
2926
|
+
Self {
|
|
2927
|
+
screens: Mutex::new(Vec::new()),
|
|
2928
|
+
sent: Mutex::new(Vec::new()),
|
|
2929
|
+
liveness: PaneLiveness::Unknown,
|
|
2930
|
+
targets: Vec::new(),
|
|
2931
|
+
window_options: Mutex::new(Vec::new()),
|
|
2932
|
+
window_option_error: Mutex::new(Some(error.to_string())),
|
|
2933
|
+
kills: Mutex::new(Vec::new()),
|
|
2934
|
+
windows: Mutex::new(Vec::new()),
|
|
2389
2935
|
}
|
|
2390
2936
|
}
|
|
2391
2937
|
|
|
@@ -2395,6 +2941,28 @@ mod tests {
|
|
|
2395
2941
|
Err(poisoned) => poisoned.into_inner().clone(),
|
|
2396
2942
|
}
|
|
2397
2943
|
}
|
|
2944
|
+
|
|
2945
|
+
/// All `set-window-option option value` pairs, in call order.
|
|
2946
|
+
fn window_options(&self) -> Vec<(String, String)> {
|
|
2947
|
+
match self.window_options.lock() {
|
|
2948
|
+
Ok(guard) => guard.clone(),
|
|
2949
|
+
Err(poisoned) => poisoned.into_inner().clone(),
|
|
2950
|
+
}
|
|
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
|
+
}
|
|
2398
2966
|
}
|
|
2399
2967
|
|
|
2400
2968
|
impl Transport for ScriptedTransport {
|
|
@@ -2499,7 +3067,10 @@ mod tests {
|
|
|
2499
3067
|
}
|
|
2500
3068
|
|
|
2501
3069
|
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
2502
|
-
|
|
3070
|
+
match self.windows.lock() {
|
|
3071
|
+
Ok(guard) => Ok(guard.clone()),
|
|
3072
|
+
Err(poisoned) => Ok(poisoned.into_inner().clone()),
|
|
3073
|
+
}
|
|
2503
3074
|
}
|
|
2504
3075
|
|
|
2505
3076
|
fn set_session_env(
|
|
@@ -2511,11 +3082,50 @@ mod tests {
|
|
|
2511
3082
|
Ok(SetEnvOutcome::Applied)
|
|
2512
3083
|
}
|
|
2513
3084
|
|
|
2514
|
-
fn
|
|
3085
|
+
fn set_window_option(
|
|
3086
|
+
&self,
|
|
3087
|
+
_session: &SessionName,
|
|
3088
|
+
_window: &WindowName,
|
|
3089
|
+
option: &str,
|
|
3090
|
+
value: &str,
|
|
3091
|
+
) -> Result<(), TransportError> {
|
|
3092
|
+
if let Some(stderr) = self
|
|
3093
|
+
.window_option_error
|
|
3094
|
+
.lock()
|
|
3095
|
+
.ok()
|
|
3096
|
+
.and_then(|guard| guard.clone())
|
|
3097
|
+
{
|
|
3098
|
+
return Err(TransportError::Subprocess {
|
|
3099
|
+
argv: vec!["tmux".to_string(), "set-window-option".to_string()],
|
|
3100
|
+
code: Some(1),
|
|
3101
|
+
stderr,
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
match self.window_options.lock() {
|
|
3105
|
+
Ok(mut guard) => guard.push((option.to_string(), value.to_string())),
|
|
3106
|
+
Err(poisoned) => poisoned
|
|
3107
|
+
.into_inner()
|
|
3108
|
+
.push((option.to_string(), value.to_string())),
|
|
3109
|
+
}
|
|
3110
|
+
Ok(())
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
fn kill_server(&self) -> Result<(), TransportError> {
|
|
3114
|
+
self.record_kill("kill_server");
|
|
2515
3115
|
Ok(())
|
|
2516
3116
|
}
|
|
2517
3117
|
|
|
2518
|
-
fn
|
|
3118
|
+
fn kill_session(&self, session: &SessionName) -> Result<(), TransportError> {
|
|
3119
|
+
self.record_kill(&format!("kill_session:{}", session.as_str()));
|
|
3120
|
+
Ok(())
|
|
3121
|
+
}
|
|
3122
|
+
|
|
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
|
+
}
|
|
2519
3129
|
Ok(())
|
|
2520
3130
|
}
|
|
2521
3131
|
|
|
@@ -2988,4 +3598,416 @@ mod tests {
|
|
|
2988
3598
|
assert_eq!(captured.len(), LEADER_STDERR_LIMIT);
|
|
2989
3599
|
assert!(captured.ends_with(b"XYZ"));
|
|
2990
3600
|
}
|
|
3601
|
+
|
|
3602
|
+
#[test]
|
|
3603
|
+
fn managed_leader_launch_disables_window_rename() {
|
|
3604
|
+
let transport = ScriptedTransport::with_liveness(PaneLiveness::Live);
|
|
3605
|
+
let session = SessionName::new("team-agent-leader-claude_code-demo");
|
|
3606
|
+
let window = WindowName::new("claude_code");
|
|
3607
|
+
|
|
3608
|
+
let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
|
|
3609
|
+
|
|
3610
|
+
assert_eq!(result, "ok", "both set-window-option calls must succeed");
|
|
3611
|
+
assert!(stderr.is_empty());
|
|
3612
|
+
let options = transport.window_options();
|
|
3613
|
+
assert_eq!(
|
|
3614
|
+
options,
|
|
3615
|
+
vec![
|
|
3616
|
+
("allow-rename".to_string(), "off".to_string()),
|
|
3617
|
+
("automatic-rename".to_string(), "off".to_string()),
|
|
3618
|
+
],
|
|
3619
|
+
"both rename lockdown options must be issued, in order"
|
|
3620
|
+
);
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
#[test]
|
|
3624
|
+
fn launcher_survives_set_window_option_failure() {
|
|
3625
|
+
let workspace =
|
|
3626
|
+
std::env::temp_dir().join(format!("ta-rename-lockdown-fail-{}", std::process::id()));
|
|
3627
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
3628
|
+
let transport = ScriptedTransport::with_window_option_error(
|
|
3629
|
+
"set-window-option: unknown option: allow-rename",
|
|
3630
|
+
);
|
|
3631
|
+
let session = SessionName::new("team-agent-leader-claude_code-demo");
|
|
3632
|
+
let window = WindowName::new("claude_code");
|
|
3633
|
+
|
|
3634
|
+
// Lockdown must NOT abort the launcher: it returns a failed outcome
|
|
3635
|
+
// (recorded for audit) instead of propagating an error.
|
|
3636
|
+
let (result, stderr) = lock_down_window_rename(&transport, &session, &window);
|
|
3637
|
+
|
|
3638
|
+
assert_eq!(
|
|
3639
|
+
result, "failed",
|
|
3640
|
+
"a set-window-option failure is audited, not fatal"
|
|
3641
|
+
);
|
|
3642
|
+
assert!(
|
|
3643
|
+
stderr.contains("unknown option"),
|
|
3644
|
+
"tmux stderr must surface in the audit payload: {stderr}"
|
|
3645
|
+
);
|
|
3646
|
+
|
|
3647
|
+
// The audit event lands in the workspace event log with result=failed.
|
|
3648
|
+
write_window_rename_lockdown_event(&workspace, &result, &stderr);
|
|
3649
|
+
let events = crate::event_log::EventLog::new(&workspace).tail(0).unwrap();
|
|
3650
|
+
let lockdown = events
|
|
3651
|
+
.iter()
|
|
3652
|
+
.find(|event| event["event"] == "leader.launcher.window_rename_lockdown")
|
|
3653
|
+
.expect("rename_lockdown event must be written even on failure");
|
|
3654
|
+
assert_eq!(lockdown["result"], "failed");
|
|
3655
|
+
assert!(
|
|
3656
|
+
lockdown["tmux_stderr"]
|
|
3657
|
+
.as_str()
|
|
3658
|
+
.unwrap_or_default()
|
|
3659
|
+
.contains("unknown option"),
|
|
3660
|
+
"event must carry tmux stderr for regression triage: {lockdown}"
|
|
3661
|
+
);
|
|
3662
|
+
|
|
3663
|
+
std::fs::remove_dir_all(&workspace).ok();
|
|
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
|
+
}
|
|
2991
4013
|
}
|