@team-agent/installer 0.5.60 → 0.5.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +49 -3
- package/crates/team-agent/src/cli/attach_app_server_leader.rs +1 -0
- package/crates/team-agent/src/cli/diagnose.rs +184 -0
- package/crates/team-agent/src/cli/emit.rs +104 -21
- package/crates/team-agent/src/cli/helpers.rs +1 -0
- package/crates/team-agent/src/cli/leader.rs +14 -2
- package/crates/team-agent/src/cli/leaders.rs +2 -0
- package/crates/team-agent/src/cli/mod.rs +296 -8
- package/crates/team-agent/src/cli/named_address.rs +26 -2
- package/crates/team-agent/src/cli/profile.rs +2 -1
- package/crates/team-agent/src/cli/send/coordinator.rs +1 -0
- package/crates/team-agent/src/cli/send/mailbox.rs +3 -0
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +3 -0
- package/crates/team-agent/src/cli/send/resolve.rs +206 -1
- package/crates/team-agent/src/cli/send.rs +2 -1
- package/crates/team-agent/src/cli/spec.rs +5 -1
- package/crates/team-agent/src/cli/status.rs +2 -1
- package/crates/team-agent/src/cli/status_port/compact.rs +1 -0
- package/crates/team-agent/src/cli/status_port/format.rs +1 -0
- package/crates/team-agent/src/cli/status_port/inbox.rs +1 -0
- package/crates/team-agent/src/cli/status_port/store.rs +7 -3
- package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +38 -0
- package/crates/team-agent/src/cli/types.rs +36 -2
- package/crates/team-agent/src/communication_mode/mod.rs +54 -0
- package/crates/team-agent/src/compiler/tests.rs +5 -5
- package/crates/team-agent/src/compiler.rs +54 -1
- package/crates/team-agent/src/conpty/backend.rs +1 -0
- package/crates/team-agent/src/conpty/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/backoff.rs +5 -8
- package/crates/team-agent/src/coordinator/conpty_shim.rs +3 -2
- package/crates/team-agent/src/coordinator/health.rs +1 -0
- package/crates/team-agent/src/coordinator/mod.rs +2 -2
- package/crates/team-agent/src/coordinator/orphan.rs +4 -116
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +6 -5
- package/crates/team-agent/src/coordinator/steps/delivery.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +2 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +1 -0
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +0 -174
- package/crates/team-agent/src/coordinator/tests/api_error_recovery.rs +0 -9
- package/crates/team-agent/src/coordinator/tests/basics.rs +0 -184
- package/crates/team-agent/src/coordinator/tests/mod.rs +2 -17
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +0 -42
- package/crates/team-agent/src/coordinator/tick.rs +24 -28
- package/crates/team-agent/src/coordinator/types.rs +3 -176
- package/crates/team-agent/src/db/agent_health_capture.rs +1 -0
- package/crates/team-agent/src/db/message_store.rs +47 -2
- package/crates/team-agent/src/db/migration.rs +5 -4
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/db/schema.rs +1 -0
- package/crates/team-agent/src/diagnose/comms.rs +2 -1
- package/crates/team-agent/src/diagnose/mod.rs +1 -0
- package/crates/team-agent/src/diagnose/orphans.rs +1 -0
- package/crates/team-agent/src/fake_worker.rs +21 -1
- package/crates/team-agent/src/layout/manager.rs +3 -7
- package/crates/team-agent/src/layout/mod.rs +2 -2
- package/crates/team-agent/src/layout/overlay.rs +6 -1
- package/crates/team-agent/src/layout/placement.rs +1 -0
- package/crates/team-agent/src/layout/recovery.rs +3 -0
- package/crates/team-agent/src/layout/runtime_sessions.rs +8 -3
- package/crates/team-agent/src/layout/sessions.rs +8 -1
- package/crates/team-agent/src/layout/tmux_endpoint.rs +3 -0
- package/crates/team-agent/src/layout/worker_env.rs +3 -0
- package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
- package/crates/team-agent/src/leader/helpers.rs +1 -0
- package/crates/team-agent/src/leader/incident.rs +1 -0
- package/crates/team-agent/src/leader/lease.rs +27 -22
- package/crates/team-agent/src/leader/mod.rs +2 -3
- package/crates/team-agent/src/leader/owner_bind.rs +13 -3
- package/crates/team-agent/src/leader/provider_attribution.rs +1 -0
- package/crates/team-agent/src/leader/rediscover.rs +4 -17
- package/crates/team-agent/src/leader/registry.rs +3 -15
- package/crates/team-agent/src/leader/start.rs +1072 -186
- package/crates/team-agent/src/leader/takeover.rs +10 -232
- package/crates/team-agent/src/leader/tests/claim_leader_mailbox_flush_contract.rs +544 -0
- package/crates/team-agent/src/leader/tests/historical_inventory_upgrade_gate_red.rs +624 -0
- package/crates/team-agent/src/leader/tests/identity.rs +71 -59
- package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
- package/crates/team-agent/src/leader/tests/idle.rs +0 -193
- package/crates/team-agent/src/leader/tests/lease_api.rs +0 -45
- package/crates/team-agent/src/leader/tests/mod.rs +4 -41
- package/crates/team-agent/src/leader/tests/rehomed_env.rs +83 -0
- package/crates/team-agent/src/leader/tests/team_in_team_state_scope_red.rs +1273 -0
- package/crates/team-agent/src/leader/tests/terminal_and_replay_vs_rerender_invariants_red.rs +417 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +22 -103
- package/crates/team-agent/src/leader/types.rs +1 -28
- package/crates/team-agent/src/lib.rs +7 -0
- package/crates/team-agent/src/lifecycle/display.rs +7 -1
- package/crates/team-agent/src/lifecycle/helpers.rs +3 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +2 -2
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +2 -2
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +4 -26
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +0 -27
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +2 -28
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +4 -32
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +11 -12
- package/crates/team-agent/src/lifecycle/mod.rs +2 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -4
- package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +2 -2
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -5
- package/crates/team-agent/src/lifecycle/restart/remove.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/selection.rs +3 -3
- package/crates/team-agent/src/lifecycle/restart.rs +13 -11
- package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1007 -0
- package/crates/team-agent/src/lifecycle/tests/b0_legacy_snapshot_nonauthority_contract.rs +265 -0
- package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +459 -0
- package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +849 -0
- package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +323 -0
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +423 -0
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +737 -0
- package/crates/team-agent/src/lifecycle/tests/codex_mcp_approval_inheritance_red.rs +1187 -0
- package/crates/team-agent/src/lifecycle/tests/codex_weak_window_attribution_red.rs +683 -0
- package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +394 -0
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +1856 -0
- package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +917 -0
- package/crates/team-agent/src/lifecycle/tests/display_adaptive_red.rs +481 -0
- package/crates/team-agent/src/lifecycle/tests/f032_startup_prompt_best_effort_red.rs +301 -0
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +516 -0
- package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +986 -0
- package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +651 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +2 -0
- package/crates/team-agent/src/lifecycle/tests/quick_start_worker_readiness_red.rs +332 -0
- package/crates/team-agent/src/lifecycle/tests/realmachine_clusters_1_6_red.rs +846 -0
- package/crates/team-agent/src/lifecycle/tests/realmachine_residual_g1_g4_red.rs +648 -0
- package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +910 -0
- package/crates/team-agent/src/lifecycle/tests/restart_liveness_red.rs +630 -0
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1083 -0
- package/crates/team-agent/src/lifecycle/tests/restart_session_capture_red.rs +1677 -0
- package/crates/team-agent/src/lifecycle/tests/resume_recover_red.rs +410 -0
- package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +428 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +1032 -0
- package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +869 -0
- package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +1249 -0
- package/crates/team-agent/src/lifecycle/tests/swallow_batch4_semantics_red.rs +342 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +542 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_sibling_quick_start_red.rs +1103 -0
- package/crates/team-agent/src/lifecycle/tests/team_in_team_state_scope_red.rs +1276 -0
- package/crates/team-agent/src/lifecycle/tests/team_key_retirement_roster_red.rs +145 -0
- package/crates/team-agent/src/lifecycle/tests/team_key_retirement_txn_red.rs +603 -0
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +837 -0
- package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +928 -0
- package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +951 -0
- package/crates/team-agent/src/lifecycle/tests/worker_dangerous_bypass_argv_red.rs +327 -0
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +760 -0
- package/crates/team-agent/src/lifecycle/tests.rs +59 -0
- package/crates/team-agent/src/lifecycle/types.rs +0 -9
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +29 -11
- package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +1 -0
- package/crates/team-agent/src/mcp_server/mod.rs +1 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +5 -3
- package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
- package/crates/team-agent/src/mcp_server/tools.rs +101 -66
- package/crates/team-agent/src/mcp_server/types.rs +6 -18
- package/crates/team-agent/src/mcp_server/wire.rs +32 -26
- package/crates/team-agent/src/messaging/activity.rs +1 -0
- package/crates/team-agent/src/messaging/address.rs +1 -0
- package/crates/team-agent/src/messaging/delivery.rs +109 -41
- package/crates/team-agent/src/messaging/helpers.rs +2 -1
- package/crates/team-agent/src/messaging/leader_channel.rs +34 -10
- package/crates/team-agent/src/messaging/leader_receiver.rs +42 -24
- package/crates/team-agent/src/messaging/mod.rs +10 -14
- package/crates/team-agent/src/messaging/peers.rs +2 -0
- package/crates/team-agent/src/messaging/persist.rs +3 -1
- package/crates/team-agent/src/messaging/presentation.rs +111 -1
- package/crates/team-agent/src/messaging/results.rs +166 -17
- package/crates/team-agent/src/messaging/scheduler.rs +1 -0
- package/crates/team-agent/src/messaging/selftest.rs +1 -1
- package/crates/team-agent/src/messaging/send.rs +135 -89
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +5 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
- package/crates/team-agent/src/messaging/types.rs +7 -2
- package/crates/team-agent/src/messaging/watchers.rs +24 -13
- package/crates/team-agent/src/model/enums.rs +1 -0
- package/crates/team-agent/src/model/errors.rs +1 -0
- package/crates/team-agent/src/model/ids.rs +1 -0
- package/crates/team-agent/src/model/mod.rs +2 -0
- package/crates/team-agent/src/model/name_similarity.rs +1 -0
- package/crates/team-agent/src/model/pane_authority_refusal.rs +359 -0
- package/crates/team-agent/src/model/paths.rs +1 -0
- package/crates/team-agent/src/model/permissions.rs +3 -2
- package/crates/team-agent/src/model/routing.rs +1 -0
- package/crates/team-agent/src/model/spec.rs +17 -0
- package/crates/team-agent/src/model/task_graph.rs +1 -0
- package/crates/team-agent/src/model/yaml/tests.rs +1 -0
- package/crates/team-agent/src/model/yaml.rs +1 -0
- package/crates/team-agent/src/packaging/install.rs +9 -147
- package/crates/team-agent/src/packaging/migrate.rs +2 -0
- package/crates/team-agent/src/packaging/mod.rs +1 -0
- package/crates/team-agent/src/packaging/repair.rs +2 -0
- package/crates/team-agent/src/packaging/tests.rs +29 -218
- package/crates/team-agent/src/packaging/types.rs +8 -15
- package/crates/team-agent/src/platform/argv.rs +4 -0
- package/crates/team-agent/src/platform/errors.rs +10 -1
- package/crates/team-agent/src/platform/file_lock.rs +4 -157
- package/crates/team-agent/src/platform/mod.rs +0 -61
- package/crates/team-agent/src/platform/process.rs +1 -0
- package/crates/team-agent/src/provider/adapter.rs +1 -0
- package/crates/team-agent/src/provider/adapters/claude.rs +1 -0
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +1 -0
- package/crates/team-agent/src/provider/adapters/codex.rs +1 -0
- package/crates/team-agent/src/provider/adapters/copilot.rs +1 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +1 -0
- package/crates/team-agent/src/provider/adapters/fake.rs +1 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
- package/crates/team-agent/src/provider/approvals/mod.rs +1 -0
- package/crates/team-agent/src/provider/approvals/parsing.rs +2 -5
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +6 -5
- package/crates/team-agent/src/provider/classify.rs +1 -0
- package/crates/team-agent/src/provider/faults.rs +1 -26
- package/crates/team-agent/src/provider/helpers.rs +1 -0
- package/crates/team-agent/src/provider/mod.rs +1 -1
- package/crates/team-agent/src/provider/session/capture.rs +41 -22
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +1 -0
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +1 -0
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +1 -0
- package/crates/team-agent/src/provider/session/context_fork.rs +1 -0
- package/crates/team-agent/src/provider/session/mod.rs +2 -4
- package/crates/team-agent/src/provider/session/resume.rs +2 -209
- package/crates/team-agent/src/provider/session_scan/claude.rs +93 -3
- package/crates/team-agent/src/provider/session_scan/codex.rs +1 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +24 -7
- package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
- package/crates/team-agent/src/provider/session_scan.rs +6 -42
- package/crates/team-agent/src/provider/startup_prompt.rs +1 -0
- package/crates/team-agent/src/provider/types.rs +1 -0
- package/crates/team-agent/src/provider/wire.rs +1 -0
- package/crates/team-agent/src/state/identity.rs +16 -5
- package/crates/team-agent/src/state/identity_keys.rs +1 -0
- package/crates/team-agent/src/state/mod.rs +2 -0
- package/crates/team-agent/src/state/owner_gate.rs +5 -0
- package/crates/team-agent/src/state/ownership.rs +10 -4
- package/crates/team-agent/src/state/paths.rs +12 -0
- package/crates/team-agent/src/state/persist.rs +9 -3
- package/crates/team-agent/src/state/projection.rs +15 -2
- package/crates/team-agent/src/state/repository/intent.rs +1 -0
- package/crates/team-agent/src/state/repository/tests.rs +1 -0
- package/crates/team-agent/src/state/repository.rs +7 -0
- package/crates/team-agent/src/state/selector.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +39 -0
- package/crates/team-agent/src/tmux_backend.rs +63 -0
- package/crates/team-agent/src/topology.rs +3 -0
- package/crates/team-agent/src/transport.rs +15 -0
- package/package.json +4 -4
- package/skills/team-agent/command-coverage.json +379 -0
- package/crates/team-agent/src/leader/inject.rs +0 -33
- package/crates/team-agent/src/provider/command.rs +0 -80
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
//!
|
|
1
|
+
//!
|
|
2
|
+
//! leader::start — leader_start_plan / leader_session_name(派生 tmux session 名)。
|
|
2
3
|
|
|
3
4
|
use std::collections::BTreeMap;
|
|
4
5
|
use std::io::{IsTerminal, Read, Write};
|
|
5
6
|
use std::path::{Path, PathBuf};
|
|
6
7
|
use std::process::{Command, Stdio};
|
|
7
8
|
|
|
9
|
+
#[cfg(target_os = "linux")]
|
|
10
|
+
use std::os::fd::AsRawFd;
|
|
11
|
+
#[cfg(unix)]
|
|
12
|
+
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
|
|
13
|
+
|
|
14
|
+
use crate::model::pane_authority_refusal as pane_refusal;
|
|
8
15
|
use crate::provider::{get_adapter, Provider};
|
|
9
16
|
use crate::tmux_backend::TmuxBackend;
|
|
10
17
|
use crate::transport::{
|
|
11
|
-
PaneId, PaneLiveness, SessionName, SpawnResult, Target, Transport, WindowName,
|
|
18
|
+
PaneId, PaneInfo, PaneLiveness, SessionName, SpawnResult, Target, Transport, WindowName,
|
|
12
19
|
};
|
|
13
20
|
|
|
14
21
|
use super::helpers::{
|
|
@@ -20,11 +27,55 @@ use super::{
|
|
|
20
27
|
LeaderStartMode, LeaderStartPlan,
|
|
21
28
|
};
|
|
22
29
|
|
|
23
|
-
// ── leader::start — leader_start_plan /
|
|
30
|
+
// ── leader::start — leader_start_plan / session 名 ──
|
|
31
|
+
|
|
32
|
+
pub(crate) struct PreparedLeaderStart {
|
|
33
|
+
plan: LeaderStartPlan,
|
|
34
|
+
ambient_authority: Option<VerifiedAmbientPaneAuthority>,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[derive(Clone, Copy)]
|
|
38
|
+
enum ManagedClientAttachMode {
|
|
39
|
+
AttachSession,
|
|
40
|
+
SwitchClient,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DIFFERENT_TMUX_SERVER_PREFIX: &str =
|
|
44
|
+
"managed launcher refuses a different ambient tmux server";
|
|
45
|
+
|
|
46
|
+
impl PreparedLeaderStart {
|
|
47
|
+
pub(crate) fn plan(&self) -> &LeaderStartPlan {
|
|
48
|
+
&self.plan
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pub(crate) enum PrepareLeaderStartError {
|
|
53
|
+
PaneAuthorityRefused(pane_refusal::PaneAuthorityRefusal),
|
|
54
|
+
Leader(LeaderError),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
impl PrepareLeaderStartError {
|
|
58
|
+
fn into_leader_error(self) -> LeaderError {
|
|
59
|
+
match self {
|
|
60
|
+
Self::PaneAuthorityRefused(refusal) => LeaderError::Validation(format!(
|
|
61
|
+
"{}; open a new terminal outside the current tmux/pane or run \
|
|
62
|
+
`team-agent attach-leader` from the intended workspace pane",
|
|
63
|
+
refusal.reason().as_str()
|
|
64
|
+
)),
|
|
65
|
+
Self::Leader(error) => error,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
impl From<LeaderError> for PrepareLeaderStartError {
|
|
71
|
+
fn from(error: LeaderError) -> Self {
|
|
72
|
+
Self::Leader(error)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
24
75
|
|
|
25
76
|
/// `leader_start_plan`(card §46;`__init__.py:82`)。计算 leader 启动计划
|
|
26
77
|
/// (exec in-TMUX / new tmux session / attach existing)。provider 未安装 → `Err(Start)`。
|
|
27
|
-
pub fn leader_start_plan(
|
|
78
|
+
pub(crate) fn leader_start_plan(
|
|
28
79
|
provider: Provider,
|
|
29
80
|
provider_args: &[String],
|
|
30
81
|
workspace: &Path,
|
|
@@ -32,6 +83,122 @@ pub fn leader_start_plan(
|
|
|
32
83
|
confirm_attach: bool,
|
|
33
84
|
attach_session: Option<&SessionName>,
|
|
34
85
|
external_leader: bool,
|
|
86
|
+
) -> Result<LeaderStartPlan, LeaderError> {
|
|
87
|
+
prepare_leader_start(
|
|
88
|
+
provider,
|
|
89
|
+
provider_args,
|
|
90
|
+
workspace,
|
|
91
|
+
attach_existing,
|
|
92
|
+
confirm_attach,
|
|
93
|
+
attach_session,
|
|
94
|
+
external_leader,
|
|
95
|
+
)
|
|
96
|
+
.map(|prepared| prepared.plan)
|
|
97
|
+
.map_err(PrepareLeaderStartError::into_leader_error)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
pub(crate) fn prepare_leader_start(
|
|
101
|
+
provider: Provider,
|
|
102
|
+
provider_args: &[String],
|
|
103
|
+
workspace: &Path,
|
|
104
|
+
attach_existing: bool,
|
|
105
|
+
confirm_attach: bool,
|
|
106
|
+
attach_session: Option<&SessionName>,
|
|
107
|
+
external_leader: bool,
|
|
108
|
+
) -> Result<PreparedLeaderStart, PrepareLeaderStartError> {
|
|
109
|
+
prepare_leader_start_with_nested_attach(
|
|
110
|
+
provider,
|
|
111
|
+
provider_args,
|
|
112
|
+
workspace,
|
|
113
|
+
attach_existing,
|
|
114
|
+
confirm_attach,
|
|
115
|
+
attach_session,
|
|
116
|
+
external_leader,
|
|
117
|
+
false,
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pub(crate) fn prepare_leader_start_with_nested_attach(
|
|
122
|
+
provider: Provider,
|
|
123
|
+
provider_args: &[String],
|
|
124
|
+
workspace: &Path,
|
|
125
|
+
attach_existing: bool,
|
|
126
|
+
confirm_attach: bool,
|
|
127
|
+
attach_session: Option<&SessionName>,
|
|
128
|
+
external_leader: bool,
|
|
129
|
+
allow_nested_attach: bool,
|
|
130
|
+
) -> Result<PreparedLeaderStart, PrepareLeaderStartError> {
|
|
131
|
+
let explicit_external_path = external_leader || attach_existing || attach_session.is_some();
|
|
132
|
+
let state_external_path = workspace_state_uses_external_leader(workspace);
|
|
133
|
+
let (ambient_authority, managed_client_attach_mode, managed_provider_reentry) =
|
|
134
|
+
if explicit_external_path || state_external_path {
|
|
135
|
+
(ambient_pane_authority_preflight(workspace)?, None, false)
|
|
136
|
+
} else if workspace_state_is_managed_provider_reentry(workspace) {
|
|
137
|
+
(ambient_pane_authority_preflight(workspace)?, None, true)
|
|
138
|
+
} else {
|
|
139
|
+
let (authority, attach_mode) =
|
|
140
|
+
managed_launcher_ambient_route(workspace, allow_nested_attach)?;
|
|
141
|
+
(authority, attach_mode, false)
|
|
142
|
+
};
|
|
143
|
+
let plan = leader_start_plan_with_ambient_authority(
|
|
144
|
+
provider,
|
|
145
|
+
provider_args,
|
|
146
|
+
workspace,
|
|
147
|
+
attach_existing,
|
|
148
|
+
confirm_attach,
|
|
149
|
+
attach_session,
|
|
150
|
+
external_leader,
|
|
151
|
+
std::env::var_os("TMUX").is_some(),
|
|
152
|
+
managed_client_attach_mode,
|
|
153
|
+
managed_provider_reentry,
|
|
154
|
+
)?;
|
|
155
|
+
Ok(PreparedLeaderStart {
|
|
156
|
+
plan,
|
|
157
|
+
ambient_authority,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pub(crate) fn leader_start_plan_after_ambient_authority_check(
|
|
162
|
+
provider: Provider,
|
|
163
|
+
provider_args: &[String],
|
|
164
|
+
workspace: &Path,
|
|
165
|
+
attach_existing: bool,
|
|
166
|
+
confirm_attach: bool,
|
|
167
|
+
attach_session: Option<&SessionName>,
|
|
168
|
+
external_leader: bool,
|
|
169
|
+
) -> Result<LeaderStartPlan, LeaderError> {
|
|
170
|
+
let in_tmux = std::env::var_os("TMUX").is_some();
|
|
171
|
+
let explicit_external_path = external_leader || attach_existing || attach_session.is_some();
|
|
172
|
+
let managed_client_attach_mode = (!explicit_external_path).then_some(if in_tmux {
|
|
173
|
+
ManagedClientAttachMode::SwitchClient
|
|
174
|
+
} else {
|
|
175
|
+
ManagedClientAttachMode::AttachSession
|
|
176
|
+
});
|
|
177
|
+
leader_start_plan_with_ambient_authority(
|
|
178
|
+
provider,
|
|
179
|
+
provider_args,
|
|
180
|
+
workspace,
|
|
181
|
+
attach_existing,
|
|
182
|
+
confirm_attach,
|
|
183
|
+
attach_session,
|
|
184
|
+
external_leader,
|
|
185
|
+
in_tmux,
|
|
186
|
+
managed_client_attach_mode,
|
|
187
|
+
false,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
fn leader_start_plan_with_ambient_authority(
|
|
192
|
+
provider: Provider,
|
|
193
|
+
provider_args: &[String],
|
|
194
|
+
workspace: &Path,
|
|
195
|
+
attach_existing: bool,
|
|
196
|
+
confirm_attach: bool,
|
|
197
|
+
attach_session: Option<&SessionName>,
|
|
198
|
+
external_leader: bool,
|
|
199
|
+
in_tmux: bool,
|
|
200
|
+
managed_client_attach_mode: Option<ManagedClientAttachMode>,
|
|
201
|
+
managed_provider_reentry: bool,
|
|
35
202
|
) -> Result<LeaderStartPlan, LeaderError> {
|
|
36
203
|
if attach_session.is_some() && !confirm_attach {
|
|
37
204
|
return Err(LeaderError::Start(
|
|
@@ -54,29 +221,29 @@ pub fn leader_start_plan(
|
|
|
54
221
|
}
|
|
55
222
|
let state = crate::state::persist::load_runtime_state(workspace).ok();
|
|
56
223
|
let identity = leader_identity_context(workspace, None, state.as_ref())?;
|
|
57
|
-
let external_path = external_leader
|
|
224
|
+
let external_path = external_leader
|
|
225
|
+
|| attach_existing
|
|
226
|
+
|| attach_session.is_some()
|
|
227
|
+
|| state
|
|
228
|
+
.as_ref()
|
|
229
|
+
.is_some_and(crate::state::projection::state_is_external_leader);
|
|
58
230
|
// 0.3.28 Step 2: managed mode now uses the SAME dedicated leader session
|
|
59
231
|
// as the external path (`team-agent-leader-<provider>-<folder>-<sha1[:8]>`)
|
|
60
232
|
// — Python parity. Pre-0.3.28 the managed branch used
|
|
61
233
|
// `managed_team_session_name(identity) = team-<team_id>` which is the
|
|
62
234
|
// worker session — that co-location is the structural root of
|
|
63
235
|
// E49/E51/E53/E57-3/E60.
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// time. Pre-fix the managed path used the workspace-keyed name (same
|
|
69
|
-
// session for every entry) and silently attached the second client →
|
|
70
|
-
// UI mirror. The external/attach paths keep the stable workspace-keyed
|
|
71
|
-
// name (per-launch nonce would break `--attach-session` reattach).
|
|
236
|
+
// A-22: managed mode reuses a live provider leader session by prefix;
|
|
237
|
+
// when no candidate exists, it falls back to a per-launch nonce. The
|
|
238
|
+
// external/attach paths keep the stable workspace-keyed name because a
|
|
239
|
+
// nonce would break `--attach-session` reattach.
|
|
72
240
|
let session_name = if external_path {
|
|
73
241
|
attach_session
|
|
74
242
|
.cloned()
|
|
75
243
|
.or_else(|| Some(leader_session_name(provider, workspace)))
|
|
76
244
|
} else {
|
|
77
|
-
Some(
|
|
245
|
+
Some(managed_leader_session_for_launch(provider, workspace))
|
|
78
246
|
};
|
|
79
|
-
let in_tmux = std::env::var_os("TMUX").is_some();
|
|
80
247
|
if !in_tmux {
|
|
81
248
|
ensure_tmux_installed()?;
|
|
82
249
|
}
|
|
@@ -89,7 +256,7 @@ pub fn leader_start_plan(
|
|
|
89
256
|
} else {
|
|
90
257
|
false
|
|
91
258
|
};
|
|
92
|
-
let mode = if
|
|
259
|
+
let mode = if managed_provider_reentry {
|
|
93
260
|
LeaderStartMode::ExecProvider
|
|
94
261
|
} else if !external_path {
|
|
95
262
|
LeaderStartMode::ManagedTmuxClient
|
|
@@ -108,6 +275,7 @@ pub fn leader_start_plan(
|
|
|
108
275
|
workspace,
|
|
109
276
|
session_name.as_ref(),
|
|
110
277
|
&leader_env,
|
|
278
|
+
managed_client_attach_mode,
|
|
111
279
|
)?;
|
|
112
280
|
let plan_session_name = if mode == LeaderStartMode::ExecProvider && !external_path {
|
|
113
281
|
None
|
|
@@ -177,44 +345,21 @@ pub(crate) fn leader_env_for_identity(
|
|
|
177
345
|
leader_env
|
|
178
346
|
}
|
|
179
347
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
pub fn start_leader(
|
|
183
|
-
provider: Provider,
|
|
184
|
-
provider_args: &[String],
|
|
348
|
+
pub(crate) fn execute_prepared_leader_start(
|
|
349
|
+
prepared: &PreparedLeaderStart,
|
|
185
350
|
workspace: &Path,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
external_leader: bool,
|
|
190
|
-
) -> Result<(), LeaderError> {
|
|
191
|
-
let plan = leader_start_plan(
|
|
192
|
-
provider,
|
|
193
|
-
provider_args,
|
|
351
|
+
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
352
|
+
execute_leader_plan_after_ambient_authority(
|
|
353
|
+
&prepared.plan,
|
|
194
354
|
workspace,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
attach_session,
|
|
198
|
-
external_leader,
|
|
199
|
-
)?;
|
|
200
|
-
crate::event_log::EventLog::new(workspace).write(
|
|
201
|
-
super::LeaderEvent::LeaderStart.name(),
|
|
202
|
-
serde_json::json!({
|
|
203
|
-
"provider": super::helpers::provider_wire(plan.provider),
|
|
204
|
-
"mode": serde_json::to_value(plan.mode)?,
|
|
205
|
-
"session_name": plan.session_name.as_ref().map(|s| s.as_str().to_string()),
|
|
206
|
-
}),
|
|
207
|
-
)?;
|
|
208
|
-
execute_leader_plan(&plan, workspace).map(|_| ())
|
|
355
|
+
prepared.ambient_authority.as_ref(),
|
|
356
|
+
)
|
|
209
357
|
}
|
|
210
358
|
|
|
211
|
-
|
|
212
|
-
///
|
|
213
|
-
/// S0 exposes the seam and return model only. Lane 2 owns the real provider/tmux
|
|
214
|
-
/// execution and workspace-socket enforcement.
|
|
215
|
-
pub fn execute_leader_plan(
|
|
359
|
+
fn execute_leader_plan_after_ambient_authority(
|
|
216
360
|
plan: &LeaderStartPlan,
|
|
217
361
|
workspace: &Path,
|
|
362
|
+
ambient_authority: Option<&VerifiedAmbientPaneAuthority>,
|
|
218
363
|
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
219
364
|
if plan.mode == LeaderStartMode::ManagedTmuxClient {
|
|
220
365
|
return execute_managed_leader_plan(plan, workspace);
|
|
@@ -223,21 +368,31 @@ pub fn execute_leader_plan(
|
|
|
223
368
|
let detached = plan.mode == LeaderStartMode::NewTmuxSession
|
|
224
369
|
&& !std::io::stdin().is_terminal()
|
|
225
370
|
&& insert_detach_flag(&mut argv);
|
|
226
|
-
|
|
371
|
+
let bind_verified_ambient = plan.mode == LeaderStartMode::ExecProvider
|
|
372
|
+
&& (!plan.is_external_leader || workspace_state_uses_external_leader(workspace));
|
|
373
|
+
if bind_verified_ambient {
|
|
374
|
+
let authority = ambient_authority.ok_or_else(|| {
|
|
375
|
+
LeaderError::Validation("exec provider ambient pane authority missing".to_string())
|
|
376
|
+
})?;
|
|
227
377
|
// 0.5.35 (`.team/artifacts/managed-leader-provider-reentry-locate.md`
|
|
228
378
|
// §5/§6): three-way classify BEFORE persist. Same physical pane =
|
|
229
379
|
// provider process replacement (refresh only). Different pane with
|
|
230
380
|
// an existing canonical binding = no canonical rewrite (claim /
|
|
231
381
|
// takeover is the recovery path, §7). Unbound = first-time write.
|
|
232
|
-
match classify_exec_provider_binding(workspace, plan)? {
|
|
382
|
+
match classify_exec_provider_binding(workspace, plan, authority)? {
|
|
233
383
|
ExecProviderBinding::ManagedReentry => {
|
|
234
|
-
refresh_managed_leader_provider_binding(plan, workspace)?;
|
|
384
|
+
refresh_managed_leader_provider_binding(plan, workspace, authority)?;
|
|
235
385
|
}
|
|
236
386
|
ExecProviderBinding::DifferentPaneAlreadyBound => {
|
|
237
387
|
// No canonical state write. Provider still runs below.
|
|
238
388
|
}
|
|
239
389
|
ExecProviderBinding::Unbound => {
|
|
240
|
-
persist_exec_provider_leader_binding(
|
|
390
|
+
persist_exec_provider_leader_binding(
|
|
391
|
+
plan,
|
|
392
|
+
workspace,
|
|
393
|
+
authority,
|
|
394
|
+
plan.is_external_leader,
|
|
395
|
+
)?;
|
|
241
396
|
}
|
|
242
397
|
}
|
|
243
398
|
} else if plan.is_external_leader {
|
|
@@ -266,6 +421,402 @@ pub fn execute_leader_plan(
|
|
|
266
421
|
}
|
|
267
422
|
}
|
|
268
423
|
|
|
424
|
+
struct VerifiedAmbientPaneAuthority {
|
|
425
|
+
pane_id: PaneId,
|
|
426
|
+
observed: PaneInfo,
|
|
427
|
+
endpoint: String,
|
|
428
|
+
tmux: String,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
fn workspace_state_uses_external_leader(workspace: &Path) -> bool {
|
|
432
|
+
crate::state::persist::load_runtime_state_without_migrations(workspace)
|
|
433
|
+
.ok()
|
|
434
|
+
.as_ref()
|
|
435
|
+
.is_some_and(crate::state::projection::state_is_external_leader)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
fn workspace_state_is_managed_provider_reentry(workspace: &Path) -> bool {
|
|
439
|
+
let Ok(state) = crate::state::persist::load_runtime_state_without_migrations(workspace) else {
|
|
440
|
+
return false;
|
|
441
|
+
};
|
|
442
|
+
if crate::state::projection::state_is_external_leader(&state) {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
let Some(team_key) = state
|
|
446
|
+
.get("active_team_key")
|
|
447
|
+
.and_then(serde_json::Value::as_str)
|
|
448
|
+
else {
|
|
449
|
+
return false;
|
|
450
|
+
};
|
|
451
|
+
let Some(receiver) = state
|
|
452
|
+
.get("teams")
|
|
453
|
+
.and_then(serde_json::Value::as_object)
|
|
454
|
+
.and_then(|teams| teams.get(team_key))
|
|
455
|
+
.and_then(|team| team.get("leader_receiver"))
|
|
456
|
+
else {
|
|
457
|
+
return false;
|
|
458
|
+
};
|
|
459
|
+
let Some(pane) = std::env::var_os("TMUX_PANE") else {
|
|
460
|
+
return false;
|
|
461
|
+
};
|
|
462
|
+
let Some(tmux) = std::env::var_os("TMUX") else {
|
|
463
|
+
return false;
|
|
464
|
+
};
|
|
465
|
+
let tmux = tmux.to_string_lossy();
|
|
466
|
+
let endpoint = tmux.split(',').next().unwrap_or("").trim();
|
|
467
|
+
receiver.get("status").and_then(serde_json::Value::as_str) == Some("attached")
|
|
468
|
+
&& receiver.get("pane_id").and_then(serde_json::Value::as_str) == pane.to_str()
|
|
469
|
+
&& receiver
|
|
470
|
+
.get("tmux_socket")
|
|
471
|
+
.and_then(serde_json::Value::as_str)
|
|
472
|
+
== Some(endpoint)
|
|
473
|
+
&& receiver
|
|
474
|
+
.get("session_name")
|
|
475
|
+
.and_then(serde_json::Value::as_str)
|
|
476
|
+
.is_some_and(|session| session.starts_with(LEADER_SESSION_PREFIX))
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
fn ambient_pane_authority_preflight(
|
|
480
|
+
workspace: &Path,
|
|
481
|
+
) -> Result<Option<VerifiedAmbientPaneAuthority>, PrepareLeaderStartError> {
|
|
482
|
+
let Some(tmux) = std::env::var_os("TMUX") else {
|
|
483
|
+
return Ok(None);
|
|
484
|
+
};
|
|
485
|
+
let tmux = tmux.into_string().map_err(|_| {
|
|
486
|
+
PrepareLeaderStartError::PaneAuthorityRefused(ambient_tmux_endpoint_refusal(
|
|
487
|
+
workspace,
|
|
488
|
+
pane_refusal::AmbientTmuxEndpointUnavailableCause::TmuxValueNotUnicode,
|
|
489
|
+
))
|
|
490
|
+
})?;
|
|
491
|
+
verified_ambient_pane_authority(workspace, &tmux)
|
|
492
|
+
.map(Some)
|
|
493
|
+
.map_err(PrepareLeaderStartError::PaneAuthorityRefused)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
fn managed_launcher_ambient_route(
|
|
497
|
+
workspace: &Path,
|
|
498
|
+
allow_nested_attach: bool,
|
|
499
|
+
) -> Result<
|
|
500
|
+
(
|
|
501
|
+
Option<VerifiedAmbientPaneAuthority>,
|
|
502
|
+
Option<ManagedClientAttachMode>,
|
|
503
|
+
),
|
|
504
|
+
PrepareLeaderStartError,
|
|
505
|
+
> {
|
|
506
|
+
let Some(tmux) = std::env::var_os("TMUX") else {
|
|
507
|
+
return Ok((None, Some(ManagedClientAttachMode::AttachSession)));
|
|
508
|
+
};
|
|
509
|
+
let tmux = tmux.into_string().map_err(|_| {
|
|
510
|
+
PrepareLeaderStartError::PaneAuthorityRefused(ambient_tmux_endpoint_refusal(
|
|
511
|
+
workspace,
|
|
512
|
+
pane_refusal::AmbientTmuxEndpointUnavailableCause::TmuxValueNotUnicode,
|
|
513
|
+
))
|
|
514
|
+
})?;
|
|
515
|
+
let observed_endpoint = validated_ambient_tmux_endpoint(workspace, &tmux)
|
|
516
|
+
.map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
|
|
517
|
+
let target_socket_name = TmuxBackend::for_workspace(workspace)
|
|
518
|
+
.tmux_endpoint()
|
|
519
|
+
.ok_or_else(|| LeaderError::Start("workspace tmux endpoint missing".to_string()))?;
|
|
520
|
+
let target_endpoint = crate::tmux_backend::socket_path_for_workspace(workspace);
|
|
521
|
+
let same_server = target_endpoint.as_ref().is_some_and(|target| {
|
|
522
|
+
let observed = Path::new(&observed_endpoint)
|
|
523
|
+
.canonicalize()
|
|
524
|
+
.unwrap_or_else(|_| PathBuf::from(&observed_endpoint));
|
|
525
|
+
let target = target.canonicalize().unwrap_or_else(|_| target.clone());
|
|
526
|
+
observed == target
|
|
527
|
+
});
|
|
528
|
+
if same_server {
|
|
529
|
+
let authority = verified_ambient_pane_authority(workspace, &tmux)
|
|
530
|
+
.map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
|
|
531
|
+
return Ok((Some(authority), Some(ManagedClientAttachMode::SwitchClient)));
|
|
532
|
+
}
|
|
533
|
+
if !allow_nested_attach {
|
|
534
|
+
return Err(LeaderError::Validation(format!(
|
|
535
|
+
"{DIFFERENT_TMUX_SERVER_PREFIX}: observed_endpoint={observed_endpoint}; \
|
|
536
|
+
requested_workspace_socket={target_socket_name}"
|
|
537
|
+
))
|
|
538
|
+
.into());
|
|
539
|
+
}
|
|
540
|
+
let authority = verified_ambient_pane_authority(workspace, &tmux)
|
|
541
|
+
.map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
|
|
542
|
+
Ok((
|
|
543
|
+
Some(authority),
|
|
544
|
+
Some(ManagedClientAttachMode::AttachSession),
|
|
545
|
+
))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
fn validated_ambient_tmux_endpoint(
|
|
549
|
+
workspace: &Path,
|
|
550
|
+
tmux: &str,
|
|
551
|
+
) -> Result<String, pane_refusal::PaneAuthorityRefusal> {
|
|
552
|
+
let tuple = tmux.split(',').map(str::trim).collect::<Vec<_>>();
|
|
553
|
+
let [endpoint, server_pid, session_index] = tuple.as_slice() else {
|
|
554
|
+
return Err(ambient_tmux_endpoint_refusal(
|
|
555
|
+
workspace,
|
|
556
|
+
pane_refusal::AmbientTmuxEndpointUnavailableCause::TmuxTupleFieldCountInvalid,
|
|
557
|
+
));
|
|
558
|
+
};
|
|
559
|
+
if endpoint.is_empty() {
|
|
560
|
+
return Err(ambient_tmux_endpoint_refusal(
|
|
561
|
+
workspace,
|
|
562
|
+
pane_refusal::AmbientTmuxEndpointUnavailableCause::EndpointMissing,
|
|
563
|
+
));
|
|
564
|
+
}
|
|
565
|
+
if !Path::new(endpoint).is_absolute() {
|
|
566
|
+
return Err(ambient_tmux_endpoint_refusal(
|
|
567
|
+
workspace,
|
|
568
|
+
pane_refusal::AmbientTmuxEndpointUnavailableCause::EndpointNotAbsolute,
|
|
569
|
+
));
|
|
570
|
+
}
|
|
571
|
+
if server_pid.parse::<u32>().is_err() {
|
|
572
|
+
return Err(ambient_tmux_endpoint_refusal(
|
|
573
|
+
workspace,
|
|
574
|
+
pane_refusal::AmbientTmuxEndpointUnavailableCause::ServerPidInvalid,
|
|
575
|
+
));
|
|
576
|
+
}
|
|
577
|
+
if session_index.parse::<u32>().is_err() {
|
|
578
|
+
return Err(ambient_tmux_endpoint_refusal(
|
|
579
|
+
workspace,
|
|
580
|
+
pane_refusal::AmbientTmuxEndpointUnavailableCause::SessionIndexInvalid,
|
|
581
|
+
));
|
|
582
|
+
}
|
|
583
|
+
Ok((*endpoint).to_string())
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
fn verified_ambient_pane_authority(
|
|
587
|
+
workspace: &Path,
|
|
588
|
+
tmux: &str,
|
|
589
|
+
) -> Result<VerifiedAmbientPaneAuthority, pane_refusal::PaneAuthorityRefusal> {
|
|
590
|
+
let requested_workspace = resolve_workspace_for_hash(workspace);
|
|
591
|
+
let endpoint = validated_ambient_tmux_endpoint(workspace, tmux)?;
|
|
592
|
+
let pane_id = match std::env::var_os("TMUX_PANE") {
|
|
593
|
+
None => {
|
|
594
|
+
return Err(pane_refusal::PaneAuthorityRefusal::new(
|
|
595
|
+
pane_refusal::PaneAuthorityRefusalFacts::AmbientPaneIdUnavailable(
|
|
596
|
+
pane_refusal::AmbientPaneIdUnavailableFacts {
|
|
597
|
+
requested_workspace,
|
|
598
|
+
observed_pane_id: pane_refusal::UnavailablePaneAuthorityFact::new(
|
|
599
|
+
pane_refusal::AmbientPaneIdUnavailableCause::EnvironmentValueMissing,
|
|
600
|
+
),
|
|
601
|
+
endpoint,
|
|
602
|
+
},
|
|
603
|
+
),
|
|
604
|
+
));
|
|
605
|
+
}
|
|
606
|
+
Some(value) => match value.into_string() {
|
|
607
|
+
Ok(value) if value.trim().is_empty() => {
|
|
608
|
+
return Err(pane_refusal::PaneAuthorityRefusal::new(
|
|
609
|
+
pane_refusal::PaneAuthorityRefusalFacts::AmbientPaneIdUnavailable(
|
|
610
|
+
pane_refusal::AmbientPaneIdUnavailableFacts {
|
|
611
|
+
requested_workspace,
|
|
612
|
+
observed_pane_id: pane_refusal::UnavailablePaneAuthorityFact::new(
|
|
613
|
+
pane_refusal::AmbientPaneIdUnavailableCause::EnvironmentValueEmpty,
|
|
614
|
+
),
|
|
615
|
+
endpoint,
|
|
616
|
+
},
|
|
617
|
+
),
|
|
618
|
+
));
|
|
619
|
+
}
|
|
620
|
+
Ok(value) => PaneId::new(value.trim().to_string()),
|
|
621
|
+
Err(_) => {
|
|
622
|
+
return Err(pane_refusal::PaneAuthorityRefusal::new(
|
|
623
|
+
pane_refusal::PaneAuthorityRefusalFacts::AmbientPaneIdUnavailable(
|
|
624
|
+
pane_refusal::AmbientPaneIdUnavailableFacts {
|
|
625
|
+
requested_workspace,
|
|
626
|
+
observed_pane_id: pane_refusal::UnavailablePaneAuthorityFact::new(
|
|
627
|
+
pane_refusal::AmbientPaneIdUnavailableCause::EnvironmentValueNotUnicode,
|
|
628
|
+
),
|
|
629
|
+
endpoint,
|
|
630
|
+
},
|
|
631
|
+
),
|
|
632
|
+
));
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
let observed = current_tmux_pane_info(&pane_id, &endpoint).map_err(|error| {
|
|
637
|
+
let cause = match error {
|
|
638
|
+
CurrentTmuxPaneInfoError::QueryFailed => {
|
|
639
|
+
pane_refusal::AmbientPaneWorkspaceUnavailableCause::PaneQueryFailed
|
|
640
|
+
}
|
|
641
|
+
CurrentTmuxPaneInfoError::PaneNotFound => {
|
|
642
|
+
pane_refusal::AmbientPaneWorkspaceUnavailableCause::PaneNotFound
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
pane_refusal::PaneAuthorityRefusal::new(
|
|
646
|
+
pane_refusal::PaneAuthorityRefusalFacts::AmbientPaneWorkspaceUnavailable(
|
|
647
|
+
pane_refusal::AmbientPaneWorkspaceUnavailableFacts {
|
|
648
|
+
requested_workspace: requested_workspace.clone(),
|
|
649
|
+
observed_pane_id: pane_id.as_str().to_string(),
|
|
650
|
+
observed_pane_workspace: pane_refusal::UnavailablePaneAuthorityFact::new(cause),
|
|
651
|
+
endpoint: endpoint.clone(),
|
|
652
|
+
},
|
|
653
|
+
),
|
|
654
|
+
)
|
|
655
|
+
})?;
|
|
656
|
+
let pane_workspace = observed.current_path.clone().ok_or_else(|| {
|
|
657
|
+
pane_refusal::PaneAuthorityRefusal::new(
|
|
658
|
+
pane_refusal::PaneAuthorityRefusalFacts::AmbientPaneWorkspaceUnavailable(
|
|
659
|
+
pane_refusal::AmbientPaneWorkspaceUnavailableFacts {
|
|
660
|
+
requested_workspace: requested_workspace.clone(),
|
|
661
|
+
observed_pane_id: pane_id.as_str().to_string(),
|
|
662
|
+
observed_pane_workspace: pane_refusal::UnavailablePaneAuthorityFact::new(
|
|
663
|
+
pane_refusal::AmbientPaneWorkspaceUnavailableCause::CurrentPathMissing,
|
|
664
|
+
),
|
|
665
|
+
endpoint: endpoint.clone(),
|
|
666
|
+
},
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
})?;
|
|
670
|
+
let caller_tty = caller_controlling_tty().map_err(|cause| {
|
|
671
|
+
pane_refusal::PaneAuthorityRefusal::new(
|
|
672
|
+
pane_refusal::PaneAuthorityRefusalFacts::CallerControllingTtyUnavailable(
|
|
673
|
+
pane_refusal::CallerControllingTtyUnavailableFacts {
|
|
674
|
+
requested_workspace: requested_workspace.clone(),
|
|
675
|
+
observed_pane_id: pane_id.as_str().to_string(),
|
|
676
|
+
endpoint: endpoint.clone(),
|
|
677
|
+
caller_controlling_tty: pane_refusal::UnavailablePaneAuthorityFact::new(cause),
|
|
678
|
+
},
|
|
679
|
+
),
|
|
680
|
+
)
|
|
681
|
+
})?;
|
|
682
|
+
let pane_tty_path = observed.tty.as_deref().ok_or_else(|| {
|
|
683
|
+
pane_refusal::PaneAuthorityRefusal::new(
|
|
684
|
+
pane_refusal::PaneAuthorityRefusalFacts::ObservedPaneTtyUnavailable(
|
|
685
|
+
pane_refusal::ObservedPaneTtyUnavailableFacts {
|
|
686
|
+
requested_workspace: requested_workspace.clone(),
|
|
687
|
+
observed_pane_id: pane_id.as_str().to_string(),
|
|
688
|
+
endpoint: endpoint.clone(),
|
|
689
|
+
observed_pane_tty: pane_refusal::UnavailablePaneAuthorityFact::new(
|
|
690
|
+
pane_refusal::ObservedPaneTtyUnavailableCause::PaneTtyMissing,
|
|
691
|
+
),
|
|
692
|
+
},
|
|
693
|
+
),
|
|
694
|
+
)
|
|
695
|
+
})?;
|
|
696
|
+
let pane_tty = pane_tty_device(pane_tty_path).map_err(|cause| {
|
|
697
|
+
pane_refusal::PaneAuthorityRefusal::new(
|
|
698
|
+
pane_refusal::PaneAuthorityRefusalFacts::ObservedPaneTtyUnavailable(
|
|
699
|
+
pane_refusal::ObservedPaneTtyUnavailableFacts {
|
|
700
|
+
requested_workspace: requested_workspace.clone(),
|
|
701
|
+
observed_pane_id: pane_id.as_str().to_string(),
|
|
702
|
+
endpoint: endpoint.clone(),
|
|
703
|
+
observed_pane_tty: pane_refusal::UnavailablePaneAuthorityFact::new(cause),
|
|
704
|
+
},
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
})?;
|
|
708
|
+
if caller_tty != pane_tty {
|
|
709
|
+
return Err(pane_refusal::PaneAuthorityRefusal::new(
|
|
710
|
+
pane_refusal::PaneAuthorityRefusalFacts::PaneTtyMismatch(
|
|
711
|
+
pane_refusal::PaneTtyMismatchFacts {
|
|
712
|
+
requested_workspace,
|
|
713
|
+
observed_pane_id: pane_id.as_str().to_string(),
|
|
714
|
+
endpoint,
|
|
715
|
+
caller_controlling_tty: caller_tty,
|
|
716
|
+
observed_pane_tty: pane_tty,
|
|
717
|
+
},
|
|
718
|
+
),
|
|
719
|
+
));
|
|
720
|
+
}
|
|
721
|
+
if !crate::messaging::leader_channel::path_is_in_workspace(&pane_workspace, workspace) {
|
|
722
|
+
return Err(pane_refusal::PaneAuthorityRefusal::new(
|
|
723
|
+
pane_refusal::PaneAuthorityRefusalFacts::PaneWorkspaceMismatch(
|
|
724
|
+
pane_refusal::PaneWorkspaceMismatchFacts {
|
|
725
|
+
requested_workspace,
|
|
726
|
+
observed_pane_id: pane_id.as_str().to_string(),
|
|
727
|
+
observed_pane_workspace: pane_workspace,
|
|
728
|
+
endpoint,
|
|
729
|
+
},
|
|
730
|
+
),
|
|
731
|
+
));
|
|
732
|
+
}
|
|
733
|
+
Ok(VerifiedAmbientPaneAuthority {
|
|
734
|
+
pane_id,
|
|
735
|
+
observed,
|
|
736
|
+
endpoint,
|
|
737
|
+
tmux: tmux.to_string(),
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
fn ambient_tmux_endpoint_refusal(
|
|
742
|
+
workspace: &Path,
|
|
743
|
+
cause: pane_refusal::AmbientTmuxEndpointUnavailableCause,
|
|
744
|
+
) -> pane_refusal::PaneAuthorityRefusal {
|
|
745
|
+
pane_refusal::PaneAuthorityRefusal::new(
|
|
746
|
+
pane_refusal::PaneAuthorityRefusalFacts::AmbientTmuxEndpointUnavailable(
|
|
747
|
+
pane_refusal::AmbientTmuxEndpointUnavailableFacts {
|
|
748
|
+
requested_workspace: resolve_workspace_for_hash(workspace),
|
|
749
|
+
endpoint: pane_refusal::UnavailablePaneAuthorityFact::new(cause),
|
|
750
|
+
},
|
|
751
|
+
),
|
|
752
|
+
)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
#[cfg(target_os = "macos")]
|
|
756
|
+
fn caller_controlling_tty() -> Result<u64, pane_refusal::CallerControllingTtyUnavailableCause> {
|
|
757
|
+
// `/dev/tty` proves that this process has a controlling terminal, but its
|
|
758
|
+
// own path and rdev remain the generic alias. Query the process table for
|
|
759
|
+
// the actual controlling device identity.
|
|
760
|
+
let _controlling_tty = std::fs::File::open("/dev/tty")
|
|
761
|
+
.map_err(|_| pane_refusal::CallerControllingTtyUnavailableCause::NoControllingTty)?;
|
|
762
|
+
let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
|
|
763
|
+
let info_size = std::mem::size_of::<libc::proc_bsdinfo>();
|
|
764
|
+
let read = unsafe {
|
|
765
|
+
libc::proc_pidinfo(
|
|
766
|
+
libc::getpid(),
|
|
767
|
+
libc::PROC_PIDTBSDINFO,
|
|
768
|
+
0,
|
|
769
|
+
info.as_mut_ptr().cast(),
|
|
770
|
+
info_size as libc::c_int,
|
|
771
|
+
)
|
|
772
|
+
};
|
|
773
|
+
if read != info_size as libc::c_int {
|
|
774
|
+
return Err(pane_refusal::CallerControllingTtyUnavailableCause::DeviceIdentityUnresolvable);
|
|
775
|
+
}
|
|
776
|
+
Ok(u64::from(unsafe { info.assume_init() }.e_tdev))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
#[cfg(target_os = "linux")]
|
|
780
|
+
fn caller_controlling_tty() -> Result<u64, pane_refusal::CallerControllingTtyUnavailableCause> {
|
|
781
|
+
let controlling_tty = std::fs::OpenOptions::new()
|
|
782
|
+
.read(true)
|
|
783
|
+
.custom_flags(libc::O_NOCTTY | libc::O_CLOEXEC)
|
|
784
|
+
.open("/dev/tty")
|
|
785
|
+
.map_err(|_| pane_refusal::CallerControllingTtyUnavailableCause::NoControllingTty)?;
|
|
786
|
+
let mut device = 0 as libc::c_uint;
|
|
787
|
+
let result = unsafe { libc::ioctl(controlling_tty.as_raw_fd(), libc::TIOCGDEV, &mut device) };
|
|
788
|
+
if result != 0 {
|
|
789
|
+
return Err(pane_refusal::CallerControllingTtyUnavailableCause::DeviceIdentityUnresolvable);
|
|
790
|
+
}
|
|
791
|
+
Ok(u64::from(device))
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|
795
|
+
fn caller_controlling_tty() -> Result<u64, pane_refusal::CallerControllingTtyUnavailableCause> {
|
|
796
|
+
Err(pane_refusal::CallerControllingTtyUnavailableCause::PlatformUnsupported)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
#[cfg(unix)]
|
|
800
|
+
fn pane_tty_device(path: &str) -> Result<u64, pane_refusal::ObservedPaneTtyUnavailableCause> {
|
|
801
|
+
let pane_tty = std::fs::OpenOptions::new()
|
|
802
|
+
.read(true)
|
|
803
|
+
.custom_flags(libc::O_NOCTTY | libc::O_CLOEXEC)
|
|
804
|
+
.open(path)
|
|
805
|
+
.map_err(|_| pane_refusal::ObservedPaneTtyUnavailableCause::PaneTtyUnresolvable)?;
|
|
806
|
+
let metadata = pane_tty
|
|
807
|
+
.metadata()
|
|
808
|
+
.map_err(|_| pane_refusal::ObservedPaneTtyUnavailableCause::PaneTtyUnresolvable)?;
|
|
809
|
+
if !metadata.file_type().is_char_device() {
|
|
810
|
+
return Err(pane_refusal::ObservedPaneTtyUnavailableCause::PaneTtyNotCharacterDevice);
|
|
811
|
+
}
|
|
812
|
+
Ok(metadata.rdev())
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
#[cfg(not(unix))]
|
|
816
|
+
fn pane_tty_device(_path: &str) -> Result<u64, pane_refusal::ObservedPaneTtyUnavailableCause> {
|
|
817
|
+
Err(pane_refusal::ObservedPaneTtyUnavailableCause::PaneTtyUnresolvable)
|
|
818
|
+
}
|
|
819
|
+
|
|
269
820
|
/// B5: the deterministic leader-session naming prefix IS the ownership truth source —
|
|
270
821
|
/// shutdown's socket teardown spares sessions carrying it (no separate registry).
|
|
271
822
|
pub const LEADER_SESSION_PREFIX: &str = "team-agent-leader-";
|
|
@@ -303,7 +854,7 @@ pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName
|
|
|
303
854
|
/// External / attach paths keep the stable `leader_session_name` —
|
|
304
855
|
/// per-launch nonce would break `--attach-session <name>` reattach
|
|
305
856
|
/// semantics.
|
|
306
|
-
|
|
857
|
+
fn managed_leader_session_name(provider: Provider, workspace: &Path) -> SessionName {
|
|
307
858
|
let resolved = resolve_workspace_for_hash(workspace);
|
|
308
859
|
let folder_raw = resolved
|
|
309
860
|
.file_name()
|
|
@@ -322,6 +873,32 @@ pub fn managed_leader_session_name(provider: Provider, workspace: &Path) -> Sess
|
|
|
322
873
|
))
|
|
323
874
|
}
|
|
324
875
|
|
|
876
|
+
fn managed_leader_session_for_launch(provider: Provider, workspace: &Path) -> SessionName {
|
|
877
|
+
let candidates = crate::transport_factory::tmux_workspace_transport(workspace)
|
|
878
|
+
.list_targets()
|
|
879
|
+
.unwrap_or_default()
|
|
880
|
+
.into_iter()
|
|
881
|
+
.map(|target| target.session.as_str().to_string());
|
|
882
|
+
managed_leader_session_from_candidates(provider, workspace, candidates)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
fn managed_leader_session_from_candidates(
|
|
886
|
+
provider: Provider,
|
|
887
|
+
workspace: &Path,
|
|
888
|
+
candidates: impl IntoIterator<Item = String>,
|
|
889
|
+
) -> SessionName {
|
|
890
|
+
let prefix = format!("{LEADER_SESSION_PREFIX}{}-", provider_wire(provider));
|
|
891
|
+
let existing = candidates
|
|
892
|
+
.into_iter()
|
|
893
|
+
.filter(|name| name.starts_with(&prefix))
|
|
894
|
+
.collect::<std::collections::BTreeSet<_>>();
|
|
895
|
+
existing
|
|
896
|
+
.into_iter()
|
|
897
|
+
.next()
|
|
898
|
+
.map(SessionName::new)
|
|
899
|
+
.unwrap_or_else(|| managed_leader_session_name(provider, workspace))
|
|
900
|
+
}
|
|
901
|
+
|
|
325
902
|
fn start_argv(
|
|
326
903
|
mode: LeaderStartMode,
|
|
327
904
|
provider: Provider,
|
|
@@ -329,6 +906,7 @@ fn start_argv(
|
|
|
329
906
|
workspace: &Path,
|
|
330
907
|
session_name: Option<&SessionName>,
|
|
331
908
|
leader_env: &BTreeMap<String, String>,
|
|
909
|
+
managed_client_attach_mode: Option<ManagedClientAttachMode>,
|
|
332
910
|
) -> Result<Vec<String>, LeaderError> {
|
|
333
911
|
let provider_cmd = provider_command_name(provider).to_string();
|
|
334
912
|
match mode {
|
|
@@ -343,7 +921,10 @@ fn start_argv(
|
|
|
343
921
|
"managed leader session missing".to_string(),
|
|
344
922
|
));
|
|
345
923
|
};
|
|
346
|
-
|
|
924
|
+
let attach_mode = managed_client_attach_mode.ok_or_else(|| {
|
|
925
|
+
LeaderError::Start("managed client attach mode missing".to_string())
|
|
926
|
+
})?;
|
|
927
|
+
managed_client_argv(workspace, session, provider, attach_mode)
|
|
347
928
|
}
|
|
348
929
|
LeaderStartMode::AttachExisting => {
|
|
349
930
|
let Some(session) = session_name else {
|
|
@@ -407,35 +988,34 @@ fn normalized_provider_args(provider_args: &[String]) -> impl Iterator<Item = St
|
|
|
407
988
|
.cloned()
|
|
408
989
|
}
|
|
409
990
|
|
|
410
|
-
// 0.3.28 Step 2: `managed_team_session_name` deleted. Both
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
// root of E49/E51/E53/E57-3/E60.
|
|
991
|
+
// 0.3.28 Step 2: `managed_team_session_name` deleted. Both paths use the
|
|
992
|
+
// dedicated leader-session namespace; managed launches select a live prefix
|
|
993
|
+
// candidate before falling back to the nonce helper, while external/attach
|
|
994
|
+
// launches use the stable `leader_session_name`.
|
|
415
995
|
|
|
416
996
|
fn managed_client_argv(
|
|
417
997
|
workspace: &Path,
|
|
418
998
|
session: &SessionName,
|
|
419
999
|
provider: Provider,
|
|
1000
|
+
attach_mode: ManagedClientAttachMode,
|
|
420
1001
|
) -> Result<Vec<String>, LeaderError> {
|
|
421
1002
|
// 0.3.28 Step 2: leader window inside the dedicated leader session is
|
|
422
1003
|
// named after `provider_wire(provider)` (e.g. `claude`, `codex`, `fake`),
|
|
423
1004
|
// never the literal `leader`. Pre-0.3.28 this hardcoded `:leader`.
|
|
424
1005
|
let target = format!("{}:{}", session.as_str(), provider_wire(provider));
|
|
425
|
-
let argv =
|
|
426
|
-
vec![
|
|
1006
|
+
let argv = match attach_mode {
|
|
1007
|
+
ManagedClientAttachMode::SwitchClient => vec![
|
|
427
1008
|
"tmux".to_string(),
|
|
428
1009
|
"switch-client".to_string(),
|
|
429
1010
|
"-t".to_string(),
|
|
430
1011
|
target,
|
|
431
|
-
]
|
|
432
|
-
|
|
433
|
-
vec![
|
|
1012
|
+
],
|
|
1013
|
+
ManagedClientAttachMode::AttachSession => vec![
|
|
434
1014
|
"tmux".to_string(),
|
|
435
1015
|
"attach-session".to_string(),
|
|
436
1016
|
"-t".to_string(),
|
|
437
1017
|
target,
|
|
438
|
-
]
|
|
1018
|
+
],
|
|
439
1019
|
};
|
|
440
1020
|
Ok(TmuxBackend::argv_for_workspace(workspace, &argv))
|
|
441
1021
|
}
|
|
@@ -443,6 +1023,14 @@ fn managed_client_argv(
|
|
|
443
1023
|
fn execute_managed_leader_plan(
|
|
444
1024
|
plan: &LeaderStartPlan,
|
|
445
1025
|
workspace: &Path,
|
|
1026
|
+
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
1027
|
+
execute_managed_leader_plan_attempt(plan, workspace, true)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
fn execute_managed_leader_plan_attempt(
|
|
1031
|
+
plan: &LeaderStartPlan,
|
|
1032
|
+
workspace: &Path,
|
|
1033
|
+
allow_attach_recovery: bool,
|
|
446
1034
|
) -> Result<LeaderLaunchOutcome, LeaderError> {
|
|
447
1035
|
let Some(session) = plan.session_name.as_ref() else {
|
|
448
1036
|
return Err(LeaderError::Start(
|
|
@@ -460,19 +1048,76 @@ fn execute_managed_leader_plan(
|
|
|
460
1048
|
// site. Semantics unchanged; managed leader hosting stays
|
|
461
1049
|
// tmux-only per design §Batch 6.
|
|
462
1050
|
let transport = crate::transport_factory::tmux_workspace_transport(workspace);
|
|
463
|
-
let
|
|
464
|
-
|
|
1051
|
+
let session_existed_before = transport.has_session(session).map_err(|error| {
|
|
1052
|
+
LeaderError::Start(format!("managed leader session probe failed: {error}"))
|
|
1053
|
+
})?;
|
|
1054
|
+
let spawned = ensure_managed_leader_pane(
|
|
1055
|
+
&transport,
|
|
1056
|
+
session,
|
|
1057
|
+
window,
|
|
1058
|
+
plan,
|
|
1059
|
+
workspace,
|
|
1060
|
+
session_existed_before,
|
|
1061
|
+
)?;
|
|
1062
|
+
if let Err(error) = persist_managed_leader_binding(plan, workspace, &spawned) {
|
|
1063
|
+
cleanup_managed_leader_resources(
|
|
1064
|
+
&transport,
|
|
1065
|
+
session,
|
|
1066
|
+
&spawned,
|
|
1067
|
+
session_existed_before,
|
|
1068
|
+
workspace,
|
|
1069
|
+
"binding_persist_failed",
|
|
1070
|
+
);
|
|
1071
|
+
return Err(error);
|
|
1072
|
+
}
|
|
465
1073
|
spawn_managed_provider_startup_prompt_handler(
|
|
466
1074
|
plan.provider,
|
|
467
1075
|
workspace.to_path_buf(),
|
|
468
1076
|
spawned.pane_id.as_str().to_string(),
|
|
469
1077
|
);
|
|
470
|
-
let process = run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace)
|
|
1078
|
+
let process = match run_leader_argv(&plan.argv, &BTreeMap::new(), plan, workspace) {
|
|
1079
|
+
Ok(process) => process,
|
|
1080
|
+
Err(error) => {
|
|
1081
|
+
cleanup_managed_leader_resources(
|
|
1082
|
+
&transport,
|
|
1083
|
+
session,
|
|
1084
|
+
&spawned,
|
|
1085
|
+
session_existed_before,
|
|
1086
|
+
workspace,
|
|
1087
|
+
"launcher_spawn_failed",
|
|
1088
|
+
);
|
|
1089
|
+
return Err(error);
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
471
1092
|
let code = process.status.code();
|
|
472
1093
|
if !process.status.success() {
|
|
473
|
-
|
|
1094
|
+
cleanup_managed_leader_resources(
|
|
1095
|
+
&transport,
|
|
1096
|
+
session,
|
|
1097
|
+
&spawned,
|
|
1098
|
+
session_existed_before,
|
|
1099
|
+
workspace,
|
|
1100
|
+
"launcher_exit_failed",
|
|
1101
|
+
);
|
|
1102
|
+
let failure = LeaderError::Start(leader_launcher_failure(&process));
|
|
1103
|
+
if allow_attach_recovery {
|
|
1104
|
+
if let Ok(retry_plan) = managed_leader_attach_recovery_plan(plan, workspace) {
|
|
1105
|
+
return execute_managed_leader_plan_attempt(&retry_plan, workspace, false);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return Err(failure);
|
|
1109
|
+
}
|
|
1110
|
+
if let Err(error) = ensure_managed_provider_live_after_attach(&transport, &spawned) {
|
|
1111
|
+
cleanup_managed_leader_resources(
|
|
1112
|
+
&transport,
|
|
1113
|
+
session,
|
|
1114
|
+
&spawned,
|
|
1115
|
+
session_existed_before,
|
|
1116
|
+
workspace,
|
|
1117
|
+
"provider_not_live_after_attach",
|
|
1118
|
+
);
|
|
1119
|
+
return Err(error);
|
|
474
1120
|
}
|
|
475
|
-
ensure_managed_provider_live_after_attach(&transport, &spawned)?;
|
|
476
1121
|
Ok(LeaderLaunchOutcome {
|
|
477
1122
|
status: LeaderLaunchStatus::Exited,
|
|
478
1123
|
exit_code: code,
|
|
@@ -481,21 +1126,36 @@ fn execute_managed_leader_plan(
|
|
|
481
1126
|
})
|
|
482
1127
|
}
|
|
483
1128
|
|
|
1129
|
+
fn managed_leader_attach_recovery_plan(
|
|
1130
|
+
plan: &LeaderStartPlan,
|
|
1131
|
+
workspace: &Path,
|
|
1132
|
+
) -> Result<LeaderStartPlan, LeaderError> {
|
|
1133
|
+
let session = managed_leader_session_for_launch(plan.provider, workspace);
|
|
1134
|
+
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1135
|
+
ManagedClientAttachMode::SwitchClient
|
|
1136
|
+
} else {
|
|
1137
|
+
ManagedClientAttachMode::AttachSession
|
|
1138
|
+
};
|
|
1139
|
+
let argv = managed_client_argv(workspace, &session, plan.provider, attach_mode)?;
|
|
1140
|
+
let mut retry_plan = plan.clone();
|
|
1141
|
+
retry_plan.session_name = Some(session);
|
|
1142
|
+
retry_plan.argv = argv;
|
|
1143
|
+
Ok(retry_plan)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
484
1146
|
fn ensure_managed_leader_pane(
|
|
485
1147
|
transport: &dyn Transport,
|
|
486
1148
|
session: &SessionName,
|
|
487
1149
|
window: &WindowName,
|
|
488
1150
|
plan: &LeaderStartPlan,
|
|
489
1151
|
workspace: &Path,
|
|
1152
|
+
session_existed_before: bool,
|
|
490
1153
|
) -> Result<SpawnResult, LeaderError> {
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
// tmux's own duplicate-session error will surface — better than
|
|
497
|
-
// silently attaching a second client.
|
|
498
|
-
if transport.has_session(session).unwrap_or(false) {
|
|
1154
|
+
// A-22: a managed launch may reuse an existing provider leader session;
|
|
1155
|
+
// only a newly selected nonce session is absent before this probe.
|
|
1156
|
+
// Reusing the existing session adds one pane; a new session uses the
|
|
1157
|
+
// first-spawn path.
|
|
1158
|
+
if session_existed_before {
|
|
499
1159
|
// 0.4.x (CR C-1 + C-2): leader env_unset reuses the worker
|
|
500
1160
|
// provider_env_unsets (single source of truth) + spawn through the
|
|
501
1161
|
// leader shell wrapper so provider exit returns to a shell, not
|
|
@@ -530,6 +1190,45 @@ fn ensure_managed_leader_pane(
|
|
|
530
1190
|
}
|
|
531
1191
|
}
|
|
532
1192
|
|
|
1193
|
+
fn cleanup_managed_leader_resources(
|
|
1194
|
+
transport: &dyn Transport,
|
|
1195
|
+
session: &SessionName,
|
|
1196
|
+
spawned: &SpawnResult,
|
|
1197
|
+
session_existed_before: bool,
|
|
1198
|
+
workspace: &Path,
|
|
1199
|
+
reason: &str,
|
|
1200
|
+
) {
|
|
1201
|
+
let (action, result) = if session_existed_before {
|
|
1202
|
+
(
|
|
1203
|
+
"kill_pane",
|
|
1204
|
+
transport
|
|
1205
|
+
.kill_pane(&spawned.pane_id)
|
|
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
|
+
)
|
|
1217
|
+
};
|
|
1218
|
+
write_leader_startup_prompt_event(
|
|
1219
|
+
workspace,
|
|
1220
|
+
"leader.launcher.rollback",
|
|
1221
|
+
serde_json::json!({
|
|
1222
|
+
"reason": reason,
|
|
1223
|
+
"session": session.as_str(),
|
|
1224
|
+
"pane_id": spawned.pane_id.as_str(),
|
|
1225
|
+
"session_preexisted": session_existed_before,
|
|
1226
|
+
"action": action,
|
|
1227
|
+
"result": result,
|
|
1228
|
+
}),
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
533
1232
|
/// 0.4.x (CR C-1): leader provider env-unset list — SINGLE SOURCE OF TRUTH
|
|
534
1233
|
/// reused from worker spawn (`profile_launch::provider_env_unsets`).
|
|
535
1234
|
/// Audit grep guard: this function MUST be the only place in
|
|
@@ -538,7 +1237,7 @@ fn ensure_managed_leader_pane(
|
|
|
538
1237
|
/// or the underlying `provider_env_unsets`. Use `AuthMode::Subscription` —
|
|
539
1238
|
/// the leader is the user's interactive provider, never CompatibleApi/
|
|
540
1239
|
/// OfficialApi which are worker-only auth modes today.
|
|
541
|
-
|
|
1240
|
+
fn leader_env_unset_for_provider(provider: Provider) -> Vec<String> {
|
|
542
1241
|
crate::lifecycle::profile_launch::provider_env_unsets(
|
|
543
1242
|
provider,
|
|
544
1243
|
crate::model::enums::AuthMode::Subscription,
|
|
@@ -652,7 +1351,7 @@ pub enum LeaderProviderHealth {
|
|
|
652
1351
|
/// common interactive shells; missing entries here are false negatives
|
|
653
1352
|
/// (shell looks like provider absent → health says Alive) which is the
|
|
654
1353
|
/// safe default per the CR R6 conservative-Alive rule.
|
|
655
|
-
|
|
1354
|
+
fn is_interactive_shell_basename(name: &str) -> bool {
|
|
656
1355
|
let trimmed = name.trim().to_ascii_lowercase();
|
|
657
1356
|
let basename = std::path::Path::new(&trimmed)
|
|
658
1357
|
.file_name()
|
|
@@ -721,6 +1420,11 @@ fn persist_managed_leader_binding(
|
|
|
721
1420
|
let session = spawned.session.as_str().to_string();
|
|
722
1421
|
let window = spawned.window.as_str().to_string();
|
|
723
1422
|
let pane = spawned.pane_id.as_str().to_string();
|
|
1423
|
+
let attach_mode = if plan.argv.iter().any(|arg| arg == "switch-client") {
|
|
1424
|
+
"switch-client"
|
|
1425
|
+
} else {
|
|
1426
|
+
"attach-session"
|
|
1427
|
+
};
|
|
724
1428
|
let receiver = serde_json::json!({
|
|
725
1429
|
"mode": "direct_tmux",
|
|
726
1430
|
"status": "attached",
|
|
@@ -783,7 +1487,7 @@ fn persist_managed_leader_binding(
|
|
|
783
1487
|
"leader_client".to_string(),
|
|
784
1488
|
serde_json::json!({
|
|
785
1489
|
"diagnostic_only": true,
|
|
786
|
-
"attach_mode":
|
|
1490
|
+
"attach_mode": attach_mode,
|
|
787
1491
|
"tmux": std::env::var("TMUX").ok(),
|
|
788
1492
|
}),
|
|
789
1493
|
);
|
|
@@ -821,17 +1525,15 @@ fn persist_managed_leader_binding(
|
|
|
821
1525
|
fn persist_exec_provider_leader_binding(
|
|
822
1526
|
plan: &LeaderStartPlan,
|
|
823
1527
|
workspace: &Path,
|
|
1528
|
+
authority: &VerifiedAmbientPaneAuthority,
|
|
1529
|
+
is_external_leader: bool,
|
|
824
1530
|
) -> Result<(), LeaderError> {
|
|
825
1531
|
let identity = plan
|
|
826
1532
|
.identity
|
|
827
1533
|
.as_ref()
|
|
828
1534
|
.ok_or_else(|| LeaderError::Start("exec provider leader identity missing".to_string()))?;
|
|
829
|
-
let pane =
|
|
830
|
-
|
|
831
|
-
.filter(|value| !value.trim().is_empty())
|
|
832
|
-
.ok_or_else(|| LeaderError::Start("exec provider leader pane missing".to_string()))?;
|
|
833
|
-
let pane_id = PaneId::new(pane.clone());
|
|
834
|
-
let target = current_tmux_pane_info(&pane_id);
|
|
1535
|
+
let pane = authority.pane_id.as_str();
|
|
1536
|
+
let target = &authority.observed;
|
|
835
1537
|
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
836
1538
|
.unwrap_or_else(|_| serde_json::json!({}));
|
|
837
1539
|
let owner_epoch = state
|
|
@@ -846,7 +1548,7 @@ fn persist_exec_provider_leader_binding(
|
|
|
846
1548
|
.unwrap_or(0)
|
|
847
1549
|
.saturating_add(1);
|
|
848
1550
|
let now = chrono::Utc::now().to_rfc3339();
|
|
849
|
-
let socket =
|
|
1551
|
+
let socket = authority.endpoint.as_str();
|
|
850
1552
|
let provider = serde_json::to_value(plan.provider)?;
|
|
851
1553
|
let mut receiver = serde_json::json!({
|
|
852
1554
|
"mode": "direct_tmux",
|
|
@@ -859,24 +1561,18 @@ fn persist_exec_provider_leader_binding(
|
|
|
859
1561
|
"attached_at": now,
|
|
860
1562
|
"discovery": "current_pane",
|
|
861
1563
|
});
|
|
862
|
-
if let Some(
|
|
863
|
-
|
|
1564
|
+
if let Some(obj) = receiver.as_object_mut() {
|
|
1565
|
+
obj.insert(
|
|
1566
|
+
"session_name".to_string(),
|
|
1567
|
+
serde_json::json!(target.session.as_str()),
|
|
1568
|
+
);
|
|
1569
|
+
if let Some(window_name) = target.window_name.as_ref() {
|
|
864
1570
|
obj.insert(
|
|
865
|
-
"
|
|
866
|
-
serde_json::json!(
|
|
1571
|
+
"window_name".to_string(),
|
|
1572
|
+
serde_json::json!(window_name.as_str()),
|
|
867
1573
|
);
|
|
868
|
-
if let Some(window_name) = target.window_name.as_ref() {
|
|
869
|
-
obj.insert(
|
|
870
|
-
"window_name".to_string(),
|
|
871
|
-
serde_json::json!(window_name.as_str()),
|
|
872
|
-
);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
if let Some(socket) = socket.as_ref() {
|
|
877
|
-
if let Some(obj) = receiver.as_object_mut() {
|
|
878
|
-
obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
879
1574
|
}
|
|
1575
|
+
obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
880
1576
|
}
|
|
881
1577
|
let owner = serde_json::json!({
|
|
882
1578
|
"pane_id": pane,
|
|
@@ -893,23 +1589,22 @@ fn persist_exec_provider_leader_binding(
|
|
|
893
1589
|
"active_team_key".to_string(),
|
|
894
1590
|
serde_json::json!(identity.team_id.as_str()),
|
|
895
1591
|
);
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
obj.insert("is_external_leader".to_string(), serde_json::json!(false));
|
|
1592
|
+
obj.insert(
|
|
1593
|
+
"session_name".to_string(),
|
|
1594
|
+
serde_json::json!(target.session.as_str()),
|
|
1595
|
+
);
|
|
1596
|
+
obj.insert("tmux_endpoint".to_string(), serde_json::json!(socket));
|
|
1597
|
+
obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
|
|
1598
|
+
obj.insert(
|
|
1599
|
+
"is_external_leader".to_string(),
|
|
1600
|
+
serde_json::json!(is_external_leader),
|
|
1601
|
+
);
|
|
907
1602
|
obj.insert(
|
|
908
1603
|
"leader_client".to_string(),
|
|
909
1604
|
serde_json::json!({
|
|
910
1605
|
"diagnostic_only": true,
|
|
911
1606
|
"attach_mode": "exec-provider",
|
|
912
|
-
"tmux":
|
|
1607
|
+
"tmux": authority.tmux.as_str(),
|
|
913
1608
|
}),
|
|
914
1609
|
);
|
|
915
1610
|
}
|
|
@@ -954,13 +1649,9 @@ enum ExecProviderBinding {
|
|
|
954
1649
|
fn classify_exec_provider_binding(
|
|
955
1650
|
workspace: &Path,
|
|
956
1651
|
plan: &LeaderStartPlan,
|
|
1652
|
+
authority: &VerifiedAmbientPaneAuthority,
|
|
957
1653
|
) -> Result<ExecProviderBinding, LeaderError> {
|
|
958
|
-
let
|
|
959
|
-
.ok()
|
|
960
|
-
.filter(|value| !value.trim().is_empty())
|
|
961
|
-
else {
|
|
962
|
-
return Ok(ExecProviderBinding::Unbound);
|
|
963
|
-
};
|
|
1654
|
+
let pane = authority.pane_id.as_str();
|
|
964
1655
|
let state = match crate::state::persist::load_runtime_state(workspace) {
|
|
965
1656
|
Ok(state) => state,
|
|
966
1657
|
Err(_) => return Ok(ExecProviderBinding::Unbound),
|
|
@@ -996,12 +1687,11 @@ fn classify_exec_provider_binding(
|
|
|
996
1687
|
if receiver_pane != pane {
|
|
997
1688
|
return Ok(ExecProviderBinding::DifferentPaneAlreadyBound);
|
|
998
1689
|
}
|
|
999
|
-
let
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
.
|
|
1003
|
-
.
|
|
1004
|
-
.unwrap_or(false);
|
|
1690
|
+
let leader_prefixed_session = authority
|
|
1691
|
+
.observed
|
|
1692
|
+
.session
|
|
1693
|
+
.as_str()
|
|
1694
|
+
.starts_with(LEADER_SESSION_PREFIX);
|
|
1005
1695
|
if !leader_prefixed_session {
|
|
1006
1696
|
return Ok(ExecProviderBinding::DifferentPaneAlreadyBound);
|
|
1007
1697
|
}
|
|
@@ -1010,8 +1700,7 @@ fn classify_exec_provider_binding(
|
|
|
1010
1700
|
.and_then(serde_json::Value::as_str)
|
|
1011
1701
|
.filter(|s| !s.is_empty());
|
|
1012
1702
|
if let Some(recorded_socket) = recorded_socket {
|
|
1013
|
-
|
|
1014
|
-
if caller_socket.as_deref() != Some(recorded_socket) {
|
|
1703
|
+
if authority.endpoint != recorded_socket {
|
|
1015
1704
|
return Ok(ExecProviderBinding::DifferentPaneAlreadyBound);
|
|
1016
1705
|
}
|
|
1017
1706
|
}
|
|
@@ -1027,16 +1716,13 @@ fn classify_exec_provider_binding(
|
|
|
1027
1716
|
fn refresh_managed_leader_provider_binding(
|
|
1028
1717
|
plan: &LeaderStartPlan,
|
|
1029
1718
|
workspace: &Path,
|
|
1719
|
+
authority: &VerifiedAmbientPaneAuthority,
|
|
1030
1720
|
) -> Result<(), LeaderError> {
|
|
1031
1721
|
let identity = plan.identity.as_ref().ok_or_else(|| {
|
|
1032
1722
|
LeaderError::Start("managed leader re-entry identity missing".to_string())
|
|
1033
1723
|
})?;
|
|
1034
|
-
let pane =
|
|
1035
|
-
|
|
1036
|
-
.filter(|value| !value.trim().is_empty())
|
|
1037
|
-
.ok_or_else(|| LeaderError::Start("managed leader re-entry pane missing".to_string()))?;
|
|
1038
|
-
let pane_id = PaneId::new(pane.clone());
|
|
1039
|
-
let target = current_tmux_pane_info(&pane_id);
|
|
1724
|
+
let pane = authority.pane_id.as_str();
|
|
1725
|
+
let target = &authority.observed;
|
|
1040
1726
|
let mut state = crate::state::persist::load_runtime_state(workspace)
|
|
1041
1727
|
.unwrap_or_else(|_| serde_json::json!({}));
|
|
1042
1728
|
let team_key = identity.team_id.as_str();
|
|
@@ -1078,20 +1764,18 @@ fn refresh_managed_leader_provider_binding(
|
|
|
1078
1764
|
obj.insert("owner_epoch".to_string(), serde_json::json!(existing_epoch));
|
|
1079
1765
|
obj.entry("leader_session_uuid".to_string())
|
|
1080
1766
|
.or_insert_with(|| serde_json::json!(identity.leader_session_uuid.as_str()));
|
|
1081
|
-
|
|
1767
|
+
obj.insert(
|
|
1768
|
+
"session_name".to_string(),
|
|
1769
|
+
serde_json::json!(target.session.as_str()),
|
|
1770
|
+
);
|
|
1771
|
+
if let Some(window_name) = target.window_name.as_ref() {
|
|
1082
1772
|
obj.insert(
|
|
1083
|
-
"
|
|
1084
|
-
serde_json::json!(
|
|
1773
|
+
"window_name".to_string(),
|
|
1774
|
+
serde_json::json!(window_name.as_str()),
|
|
1085
1775
|
);
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
serde_json::json!(window_name.as_str()),
|
|
1090
|
-
);
|
|
1091
|
-
}
|
|
1092
|
-
if let Some(pane_pid) = target.pane_pid {
|
|
1093
|
-
obj.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
|
|
1094
|
-
}
|
|
1776
|
+
}
|
|
1777
|
+
if let Some(pane_pid) = target.pane_pid {
|
|
1778
|
+
obj.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
|
|
1095
1779
|
}
|
|
1096
1780
|
}
|
|
1097
1781
|
let mut owner = match existing_owner.as_object() {
|
|
@@ -1118,7 +1802,7 @@ fn refresh_managed_leader_provider_binding(
|
|
|
1118
1802
|
serde_json::json!({
|
|
1119
1803
|
"diagnostic_only": true,
|
|
1120
1804
|
"attach_mode": "exec-provider",
|
|
1121
|
-
"tmux":
|
|
1805
|
+
"tmux": authority.tmux.as_str(),
|
|
1122
1806
|
"reentry": true,
|
|
1123
1807
|
}),
|
|
1124
1808
|
);
|
|
@@ -1138,12 +1822,21 @@ fn refresh_managed_leader_provider_binding(
|
|
|
1138
1822
|
Ok(())
|
|
1139
1823
|
}
|
|
1140
1824
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1825
|
+
enum CurrentTmuxPaneInfoError {
|
|
1826
|
+
QueryFailed,
|
|
1827
|
+
PaneNotFound,
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
fn current_tmux_pane_info(
|
|
1831
|
+
pane_id: &PaneId,
|
|
1832
|
+
endpoint: &str,
|
|
1833
|
+
) -> Result<crate::transport::PaneInfo, CurrentTmuxPaneInfoError> {
|
|
1834
|
+
crate::transport_factory::tmux_endpoint_transport(endpoint)
|
|
1143
1835
|
.list_targets()
|
|
1144
|
-
.
|
|
1836
|
+
.map_err(|_| CurrentTmuxPaneInfoError::QueryFailed)?
|
|
1145
1837
|
.into_iter()
|
|
1146
1838
|
.find(|target| target.pane_id == *pane_id)
|
|
1839
|
+
.ok_or(CurrentTmuxPaneInfoError::PaneNotFound)
|
|
1147
1840
|
}
|
|
1148
1841
|
|
|
1149
1842
|
fn persist_external_leader_topology_marker(
|
|
@@ -1211,6 +1904,13 @@ fn run_leader_argv(
|
|
|
1211
1904
|
"leader launch argv is empty".to_string(),
|
|
1212
1905
|
));
|
|
1213
1906
|
};
|
|
1907
|
+
let diagnostics_path = launcher_diagnostics_path(workspace);
|
|
1908
|
+
if let Err(error) = write_launcher_diagnostics_header(&diagnostics_path, plan, argv) {
|
|
1909
|
+
return Err(LeaderError::Start(format!(
|
|
1910
|
+
"leader launcher diagnostics unavailable; startup_stage=diagnostics_create; launcher_diagnostics={}; error={error}",
|
|
1911
|
+
diagnostics_path.display()
|
|
1912
|
+
)));
|
|
1913
|
+
}
|
|
1214
1914
|
// 0.4.x regression fix (env-leak scenario 1, in-tmux ExecProvider path):
|
|
1215
1915
|
// the managed-tmux path got the provider env-unset block via the shell
|
|
1216
1916
|
// wrapper (cb9c217), but the ExecProvider in-tmux path here is a direct
|
|
@@ -1223,11 +1923,15 @@ fn run_leader_argv(
|
|
|
1223
1923
|
// leader_env_unset_for_provider).
|
|
1224
1924
|
let env_unset = leader_env_unset_for_provider(plan.provider);
|
|
1225
1925
|
let mut command = Command::new(program);
|
|
1226
|
-
command
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1926
|
+
command.args(argv.iter().skip(1)).stdin(Stdio::inherit());
|
|
1927
|
+
let capture_stdout = !std::io::stdout().is_terminal();
|
|
1928
|
+
command.stdout(if capture_stdout {
|
|
1929
|
+
Stdio::piped()
|
|
1930
|
+
} else {
|
|
1931
|
+
Stdio::inherit()
|
|
1932
|
+
});
|
|
1933
|
+
let capture_stderr =
|
|
1934
|
+
plan.mode != LeaderStartMode::ExecProvider || !std::io::stderr().is_terminal();
|
|
1231
1935
|
command.stderr(if capture_stderr {
|
|
1232
1936
|
Stdio::piped()
|
|
1233
1937
|
} else {
|
|
@@ -1244,7 +1948,41 @@ fn run_leader_argv(
|
|
|
1244
1948
|
for key in &env_unset {
|
|
1245
1949
|
command.env_remove(key);
|
|
1246
1950
|
}
|
|
1247
|
-
|
|
1951
|
+
if plan.mode == LeaderStartMode::ManagedTmuxClient
|
|
1952
|
+
&& argv.iter().any(|arg| arg == "attach-session")
|
|
1953
|
+
{
|
|
1954
|
+
command.env_remove("TMUX");
|
|
1955
|
+
command.env_remove("TMUX_PANE");
|
|
1956
|
+
}
|
|
1957
|
+
let mut child = command.spawn().map_err(|error| {
|
|
1958
|
+
LeaderError::Start(format!(
|
|
1959
|
+
"leader launcher spawn failed; startup_stage=spawn; launcher_diagnostics={}; error={error}",
|
|
1960
|
+
diagnostics_path.display()
|
|
1961
|
+
))
|
|
1962
|
+
})?;
|
|
1963
|
+
append_launcher_diagnostics(&diagnostics_path, "startup_stage=spawned\n").map_err(|error| {
|
|
1964
|
+
LeaderError::Start(format!(
|
|
1965
|
+
"leader launcher diagnostics write failed; startup_stage=spawned; launcher_diagnostics={}; error={error}",
|
|
1966
|
+
diagnostics_path.display()
|
|
1967
|
+
))
|
|
1968
|
+
})?;
|
|
1969
|
+
let stdout_reader = child.stdout.take().map(|mut child_stdout| {
|
|
1970
|
+
std::thread::spawn(move || {
|
|
1971
|
+
let mut captured = Vec::new();
|
|
1972
|
+
let mut buf = [0_u8; 4096];
|
|
1973
|
+
loop {
|
|
1974
|
+
let Ok(read) = child_stdout.read(&mut buf) else {
|
|
1975
|
+
break;
|
|
1976
|
+
};
|
|
1977
|
+
if read == 0 {
|
|
1978
|
+
break;
|
|
1979
|
+
}
|
|
1980
|
+
let _ = std::io::stdout().write_all(&buf[..read]);
|
|
1981
|
+
captured.extend_from_slice(&buf[..read]);
|
|
1982
|
+
}
|
|
1983
|
+
captured
|
|
1984
|
+
})
|
|
1985
|
+
});
|
|
1248
1986
|
let stderr_reader = child.stderr.take().map(|mut child_stderr| {
|
|
1249
1987
|
std::thread::spawn(move || {
|
|
1250
1988
|
let mut captured = Vec::new();
|
|
@@ -1257,7 +1995,7 @@ fn run_leader_argv(
|
|
|
1257
1995
|
break;
|
|
1258
1996
|
}
|
|
1259
1997
|
let _ = std::io::stderr().write_all(&buf[..read]);
|
|
1260
|
-
|
|
1998
|
+
captured.extend_from_slice(&buf[..read]);
|
|
1261
1999
|
}
|
|
1262
2000
|
captured
|
|
1263
2001
|
})
|
|
@@ -1266,6 +2004,13 @@ fn run_leader_argv(
|
|
|
1266
2004
|
spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
|
|
1267
2005
|
}
|
|
1268
2006
|
let status = child.wait().map_err(LeaderError::Io)?;
|
|
2007
|
+
let stdout = match stdout_reader {
|
|
2008
|
+
Some(reader) => match reader.join() {
|
|
2009
|
+
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
|
2010
|
+
Err(_) => String::new(),
|
|
2011
|
+
},
|
|
2012
|
+
None => String::new(),
|
|
2013
|
+
};
|
|
1269
2014
|
let stderr = match stderr_reader {
|
|
1270
2015
|
Some(reader) => match reader.join() {
|
|
1271
2016
|
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
|
|
@@ -1273,7 +2018,62 @@ fn run_leader_argv(
|
|
|
1273
2018
|
},
|
|
1274
2019
|
None => String::new(),
|
|
1275
2020
|
};
|
|
1276
|
-
|
|
2021
|
+
append_launcher_diagnostics(
|
|
2022
|
+
&diagnostics_path,
|
|
2023
|
+
&format!(
|
|
2024
|
+
"startup_stage=exited\nexit_code={}\nchild_stdout={}\nchild_stderr={}\n",
|
|
2025
|
+
status
|
|
2026
|
+
.code()
|
|
2027
|
+
.map(|code| code.to_string())
|
|
2028
|
+
.unwrap_or_else(|| "signal".to_string()),
|
|
2029
|
+
crate::redaction::redact_external_text(&stdout),
|
|
2030
|
+
crate::redaction::redact_external_text(&stderr),
|
|
2031
|
+
),
|
|
2032
|
+
)
|
|
2033
|
+
.map_err(|error| {
|
|
2034
|
+
LeaderError::Start(format!(
|
|
2035
|
+
"leader launcher diagnostics write failed; startup_stage=exited; launcher_diagnostics={}; error={error}",
|
|
2036
|
+
diagnostics_path.display()
|
|
2037
|
+
))
|
|
2038
|
+
})?;
|
|
2039
|
+
Ok(LeaderProcessExit {
|
|
2040
|
+
status,
|
|
2041
|
+
stderr,
|
|
2042
|
+
diagnostics_path,
|
|
2043
|
+
})
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
|
|
2047
|
+
let stamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.6f");
|
|
2048
|
+
workspace.join(".team").join("logs").join(format!(
|
|
2049
|
+
"launcher-diagnostics-{stamp}-{}.log",
|
|
2050
|
+
std::process::id()
|
|
2051
|
+
))
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
fn write_launcher_diagnostics_header(
|
|
2055
|
+
path: &Path,
|
|
2056
|
+
plan: &LeaderStartPlan,
|
|
2057
|
+
argv: &[String],
|
|
2058
|
+
) -> Result<(), std::io::Error> {
|
|
2059
|
+
if let Some(parent) = path.parent() {
|
|
2060
|
+
std::fs::create_dir_all(parent)?;
|
|
2061
|
+
}
|
|
2062
|
+
std::fs::write(
|
|
2063
|
+
path,
|
|
2064
|
+
format!(
|
|
2065
|
+
"startup_stage=prepare\nmode={:?}\nprovider={}\nargv={}\n",
|
|
2066
|
+
plan.mode,
|
|
2067
|
+
provider_wire(plan.provider),
|
|
2068
|
+
crate::redaction::redact_external_text(&argv.join(" ")),
|
|
2069
|
+
),
|
|
2070
|
+
)
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
fn append_launcher_diagnostics(path: &Path, text: &str) -> Result<(), std::io::Error> {
|
|
2074
|
+
use std::io::Write as _;
|
|
2075
|
+
let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
|
|
2076
|
+
file.write_all(text.as_bytes())
|
|
1277
2077
|
}
|
|
1278
2078
|
|
|
1279
2079
|
const LEADER_STDERR_LIMIT: usize = 8192;
|
|
@@ -1281,6 +2081,7 @@ const LEADER_STDERR_LIMIT: usize = 8192;
|
|
|
1281
2081
|
struct LeaderProcessExit {
|
|
1282
2082
|
status: std::process::ExitStatus,
|
|
1283
2083
|
stderr: String,
|
|
2084
|
+
diagnostics_path: PathBuf,
|
|
1284
2085
|
}
|
|
1285
2086
|
|
|
1286
2087
|
fn push_bounded_stderr(captured: &mut Vec<u8>, chunk: &[u8]) {
|
|
@@ -1305,7 +2106,11 @@ fn leader_launcher_failure(process: &LeaderProcessExit) -> String {
|
|
|
1305
2106
|
.code()
|
|
1306
2107
|
.map(|code| code.to_string())
|
|
1307
2108
|
.unwrap_or_else(|| "signal".to_string());
|
|
1308
|
-
|
|
2109
|
+
format!(
|
|
2110
|
+
"{}; startup_stage=exited; launcher_diagnostics={}",
|
|
2111
|
+
format_launcher_failure(&status, process.status.code().is_none(), &process.stderr),
|
|
2112
|
+
process.diagnostics_path.display()
|
|
2113
|
+
)
|
|
1309
2114
|
}
|
|
1310
2115
|
|
|
1311
2116
|
fn format_launcher_failure(status: &str, signaled: bool, raw_stderr: &str) -> String {
|
|
@@ -1399,7 +2204,7 @@ fn tmux_transport_for_current_pane() -> TmuxBackend {
|
|
|
1399
2204
|
.unwrap_or_else(crate::transport_factory::tmux_default_transport)
|
|
1400
2205
|
}
|
|
1401
2206
|
|
|
1402
|
-
|
|
2207
|
+
fn handle_exec_provider_startup_prompts(
|
|
1403
2208
|
provider: Provider,
|
|
1404
2209
|
workspace: &Path,
|
|
1405
2210
|
pane_id: &str,
|
|
@@ -1543,9 +2348,10 @@ mod tests {
|
|
|
1543
2348
|
};
|
|
1544
2349
|
|
|
1545
2350
|
use super::{
|
|
1546
|
-
ensure_managed_provider_live_after_attach,
|
|
1547
|
-
|
|
1548
|
-
|
|
2351
|
+
ensure_managed_provider_live_after_attach, execute_leader_plan_after_ambient_authority,
|
|
2352
|
+
format_launcher_failure, handle_exec_provider_startup_prompts,
|
|
2353
|
+
is_interactive_shell_basename, push_bounded_stderr, shlex_quote,
|
|
2354
|
+
VerifiedAmbientPaneAuthority, LEADER_STDERR_LIMIT,
|
|
1549
2355
|
};
|
|
1550
2356
|
|
|
1551
2357
|
struct ScriptedTransport {
|
|
@@ -1857,7 +2663,7 @@ mod tests {
|
|
|
1857
2663
|
detached: false,
|
|
1858
2664
|
};
|
|
1859
2665
|
|
|
1860
|
-
let outcome =
|
|
2666
|
+
let outcome = execute_leader_plan_after_ambient_authority(&plan, &workspace, None)
|
|
1861
2667
|
.expect("external marker must be present before provider argv runs");
|
|
1862
2668
|
|
|
1863
2669
|
assert_eq!(outcome.status, crate::leader::LeaderLaunchStatus::Exited);
|
|
@@ -1914,8 +2720,27 @@ mod tests {
|
|
|
1914
2720
|
detached: false,
|
|
1915
2721
|
};
|
|
1916
2722
|
|
|
1917
|
-
let
|
|
1918
|
-
|
|
2723
|
+
let authority = VerifiedAmbientPaneAuthority {
|
|
2724
|
+
pane_id: PaneId::new("%77"),
|
|
2725
|
+
observed: PaneInfo {
|
|
2726
|
+
pane_id: PaneId::new("%77"),
|
|
2727
|
+
session: SessionName::new("team-agent-leader-current"),
|
|
2728
|
+
window_index: Some(0),
|
|
2729
|
+
window_name: Some(WindowName::new("codex")),
|
|
2730
|
+
pane_index: Some(0),
|
|
2731
|
+
tty: Some("/dev/ttys077".to_string()),
|
|
2732
|
+
current_command: Some("codex".to_string()),
|
|
2733
|
+
current_path: Some(workspace.clone()),
|
|
2734
|
+
active: true,
|
|
2735
|
+
pane_pid: None,
|
|
2736
|
+
leader_env: BTreeMap::new(),
|
|
2737
|
+
},
|
|
2738
|
+
endpoint: "/private/tmp/tmux-501/default".to_string(),
|
|
2739
|
+
tmux: "/private/tmp/tmux-501/default,123,0".to_string(),
|
|
2740
|
+
};
|
|
2741
|
+
let outcome =
|
|
2742
|
+
execute_leader_plan_after_ambient_authority(&plan, &workspace, Some(&authority))
|
|
2743
|
+
.expect("current pane binding must be present before provider argv runs");
|
|
1919
2744
|
|
|
1920
2745
|
assert_eq!(outcome.status, crate::leader::LeaderLaunchStatus::Exited);
|
|
1921
2746
|
let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
|
|
@@ -1968,12 +2793,26 @@ mod tests {
|
|
|
1968
2793
|
assert_eq!(sent[0].1, vec![Key::Enter]);
|
|
1969
2794
|
}
|
|
1970
2795
|
|
|
2796
|
+
#[test]
|
|
2797
|
+
fn interactive_shell_basename_recognizes_supported_shells() {
|
|
2798
|
+
let shells = [
|
|
2799
|
+
"zsh", "bash", "sh", "fish", "dash", "ksh", "tcsh", "csh", "ash", "mksh", "yash",
|
|
2800
|
+
"elvish", "nu", "nushell", "xonsh",
|
|
2801
|
+
];
|
|
2802
|
+
for shell in shells {
|
|
2803
|
+
assert!(is_interactive_shell_basename(shell));
|
|
2804
|
+
assert!(is_interactive_shell_basename(&shell.to_uppercase()));
|
|
2805
|
+
}
|
|
2806
|
+
for provider in ["claude", "codex", "copilot", "gemini"] {
|
|
2807
|
+
assert!(!is_interactive_shell_basename(provider));
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
|
|
1971
2811
|
// ═══════════════════════════════════════════════════════════════════
|
|
1972
2812
|
// 0.4.10+ mirror-session fix v2 (option B: independent session per launch).
|
|
1973
2813
|
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
// tmux session, matching `tmux new-session + claude` semantics.
|
|
2814
|
+
// The managed selector reuses a live provider leader prefix when present;
|
|
2815
|
+
// its no-candidate fallback appends a per-launch nonce.
|
|
1977
2816
|
// ═══════════════════════════════════════════════════════════════════
|
|
1978
2817
|
|
|
1979
2818
|
#[test]
|
|
@@ -2046,37 +2885,84 @@ mod tests {
|
|
|
2046
2885
|
let _ = std::fs::remove_dir_all(&workspace);
|
|
2047
2886
|
}
|
|
2048
2887
|
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
/// keep `leader_session_name`.
|
|
2888
|
+
// Tombstone for `managed_path_uses_nonce_session_name_grep_guard`: A-22
|
|
2889
|
+
// replaced that implementation-shape assertion with the contract guard
|
|
2890
|
+
// below. Keep the old name here for audit/search provenance.
|
|
2053
2891
|
#[test]
|
|
2054
|
-
fn
|
|
2892
|
+
fn a22_launch_session_reuse_contract() {
|
|
2055
2893
|
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
2056
2894
|
let start_rs = manifest.join("src").join("leader").join("start.rs");
|
|
2057
2895
|
let contents = std::fs::read_to_string(&start_rs).expect("read leader/start.rs");
|
|
2058
|
-
// leader_start_plan must dispatch to managed_leader_session_name
|
|
2059
|
-
// on the managed path.
|
|
2060
2896
|
let start = contents
|
|
2061
|
-
.find("
|
|
2062
|
-
.expect("
|
|
2897
|
+
.find("fn leader_start_plan_with_ambient_authority(")
|
|
2898
|
+
.expect("leader_start_plan_with_ambient_authority must exist");
|
|
2063
2899
|
let end = contents[start + 1..]
|
|
2064
|
-
.find("\
|
|
2065
|
-
.
|
|
2066
|
-
.map(|p| start + 1 + p)
|
|
2900
|
+
.find("\nfn ")
|
|
2901
|
+
.map(|offset| start + 1 + offset)
|
|
2067
2902
|
.unwrap_or(contents.len());
|
|
2068
2903
|
let body = &contents[start..end];
|
|
2069
2904
|
assert!(
|
|
2070
|
-
body.contains("
|
|
2071
|
-
"
|
|
2072
|
-
the managed path (mirror-session fix v2); body excerpt: {body}"
|
|
2905
|
+
body.contains("managed_leader_session_for_launch(provider, workspace)"),
|
|
2906
|
+
"managed launch must select from the A-22 candidate contract; body excerpt: {body}"
|
|
2073
2907
|
);
|
|
2074
|
-
// The external_path branch must still use the stable name.
|
|
2075
2908
|
assert!(
|
|
2076
2909
|
body.contains("leader_session_name(provider, workspace)"),
|
|
2077
2910
|
"leader_start_plan must keep leader_session_name on the \
|
|
2078
2911
|
external/attach paths; body excerpt: {body}"
|
|
2079
2912
|
);
|
|
2913
|
+
let workspace =
|
|
2914
|
+
std::env::temp_dir().join(format!("ta_rs_a22_contract_{}", std::process::id()));
|
|
2915
|
+
let reused = super::managed_leader_session_from_candidates(
|
|
2916
|
+
Provider::ClaudeCode,
|
|
2917
|
+
&workspace,
|
|
2918
|
+
vec![
|
|
2919
|
+
"team-worker-not-a-leader".to_string(),
|
|
2920
|
+
"team-agent-leader-claude_code-z".to_string(),
|
|
2921
|
+
"team-agent-leader-codex-other".to_string(),
|
|
2922
|
+
"team-agent-leader-claude_code-a".to_string(),
|
|
2923
|
+
],
|
|
2924
|
+
);
|
|
2925
|
+
assert_eq!(
|
|
2926
|
+
reused.as_str(),
|
|
2927
|
+
"team-agent-leader-claude_code-a",
|
|
2928
|
+
"managed launch must deterministically reuse a matching leader prefix session"
|
|
2929
|
+
);
|
|
2930
|
+
let _ = std::fs::remove_dir_all(workspace);
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
#[test]
|
|
2934
|
+
fn a22_launch_session_without_candidate_gets_nonce() {
|
|
2935
|
+
let workspace =
|
|
2936
|
+
std::env::temp_dir().join(format!("ta_rs_a22_nonce_{}", std::process::id()));
|
|
2937
|
+
let fresh = super::managed_leader_session_from_candidates(
|
|
2938
|
+
Provider::ClaudeCode,
|
|
2939
|
+
&workspace,
|
|
2940
|
+
std::iter::empty(),
|
|
2941
|
+
);
|
|
2942
|
+
assert!(
|
|
2943
|
+
fresh.as_str().starts_with("team-agent-leader-claude_code-"),
|
|
2944
|
+
"no candidate must create a leader-prefixed managed session: {}",
|
|
2945
|
+
fresh.as_str()
|
|
2946
|
+
);
|
|
2947
|
+
assert_ne!(
|
|
2948
|
+
fresh.as_str(),
|
|
2949
|
+
super::leader_session_name(Provider::ClaudeCode, &workspace).as_str(),
|
|
2950
|
+
"managed fallback must carry a per-launch nonce"
|
|
2951
|
+
);
|
|
2952
|
+
let _ = std::fs::remove_dir_all(workspace);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
#[test]
|
|
2956
|
+
fn a22_external_attach_session_name_remains_stable() {
|
|
2957
|
+
let workspace =
|
|
2958
|
+
std::env::temp_dir().join(format!("ta_rs_a22_external_{}", std::process::id()));
|
|
2959
|
+
let stable = super::leader_session_name(Provider::ClaudeCode, &workspace);
|
|
2960
|
+
assert_eq!(
|
|
2961
|
+
stable.as_str(),
|
|
2962
|
+
super::leader_session_name(Provider::ClaudeCode, &workspace).as_str(),
|
|
2963
|
+
"external/attach paths must keep the stable workspace-keyed session name"
|
|
2964
|
+
);
|
|
2965
|
+
let _ = std::fs::remove_dir_all(workspace);
|
|
2080
2966
|
}
|
|
2081
2967
|
|
|
2082
2968
|
#[test]
|