@team-agent/installer 0.3.7 → 0.3.9
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 +52 -7
- package/crates/team-agent/src/cli/emit.rs +132 -4
- package/crates/team-agent/src/cli/leader.rs +12 -8
- package/crates/team-agent/src/cli/mod.rs +121 -28
- package/crates/team-agent/src/cli/tests/base.rs +14 -0
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
- package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
- package/crates/team-agent/src/leader/owner_bind.rs +59 -20
- package/crates/team-agent/src/leader/start.rs +34 -23
- package/crates/team-agent/src/leader/tests/identity.rs +22 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -0
- package/crates/team-agent/src/lifecycle/launch.rs +203 -16
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +25 -12
- package/crates/team-agent/src/lifecycle/restart.rs +7 -3
- package/crates/team-agent/src/lifecycle/tests/core.rs +192 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +112 -0
- package/crates/team-agent/src/lifecycle/types.rs +2 -0
- package/crates/team-agent/src/messaging/results.rs +27 -22
- package/crates/team-agent/src/messaging/tests/runtime.rs +18 -0
- package/crates/team-agent/src/provider/adapter.rs +177 -15
- package/crates/team-agent/src/state/identity.rs +29 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
- package/crates/team-agent/src/tmux_backend.rs +90 -7
- package/crates/team-agent/src/transport/test_support.rs +57 -4
- package/crates/team-agent/src/transport.rs +13 -0
- package/npm/install.mjs +31 -35
- package/package.json +4 -4
|
@@ -768,28 +768,165 @@ fn env_nonempty(key: &str) -> bool {
|
|
|
768
768
|
/// action : `unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id`
|
|
769
769
|
/// log : `TEAM_AGENT_LEADER_PANE_ID=%<id>`
|
|
770
770
|
/// env 未设(或空)→ Ok(())。
|
|
771
|
-
/// env 设了但
|
|
772
|
-
///
|
|
771
|
+
/// env 设了但 pane 可判定为 Dead/Absent → Err(RequirementUnmet)。
|
|
772
|
+
/// 真实 tmux 后端跨所有现存 tmux socket server 探测:TEAM_AGENT_LEADER_PANE_ID 是用户
|
|
773
|
+
/// override 指针,不归属当前 team socket。
|
|
774
|
+
/// probe 返 Unknown 不挡(被动路径降级):本主动路径只对【显式 Dead/Absent】fail-fast,
|
|
773
775
|
/// MUST-17 不过度设计 / unset 走 pass-through(b7_unset_leader_pane_env_passes_through 守)。
|
|
774
776
|
pub(crate) fn validate_active_leader_pane_env(
|
|
775
777
|
transport: &dyn Transport,
|
|
778
|
+
) -> Result<(), LifecycleError> {
|
|
779
|
+
validate_active_leader_pane_env_with_workspaces(transport, &[])
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
pub(crate) fn validate_active_leader_pane_env_with_workspace(
|
|
783
|
+
transport: &dyn Transport,
|
|
784
|
+
workspace: Option<&Path>,
|
|
785
|
+
) -> Result<(), LifecycleError> {
|
|
786
|
+
let workspaces = workspace.into_iter().collect::<Vec<_>>();
|
|
787
|
+
validate_active_leader_pane_env_with_workspaces(transport, &workspaces)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
pub(crate) fn validate_active_leader_pane_env_with_workspaces(
|
|
791
|
+
transport: &dyn Transport,
|
|
792
|
+
workspaces: &[&Path],
|
|
776
793
|
) -> Result<(), LifecycleError> {
|
|
777
794
|
let pane_id_raw = match std::env::var("TEAM_AGENT_LEADER_PANE_ID") {
|
|
778
795
|
Ok(v) if !v.is_empty() => v,
|
|
779
796
|
_ => return Ok(()),
|
|
780
797
|
};
|
|
781
|
-
let
|
|
782
|
-
|
|
783
|
-
|
|
798
|
+
let pane = crate::transport::PaneId::new(&pane_id_raw);
|
|
799
|
+
if !is_tmux_pane_id_format(&pane) {
|
|
800
|
+
write_invalid_leader_pane_env_warning(workspaces, &pane_id_raw);
|
|
784
801
|
return Ok(());
|
|
785
802
|
}
|
|
803
|
+
let failure = match leader_pane_env_state_for_validation(transport, &pane) {
|
|
804
|
+
LeaderPaneEnvState::Dead => Some("dead"),
|
|
805
|
+
LeaderPaneEnvState::Absent => Some("absent"),
|
|
806
|
+
LeaderPaneEnvState::Live | LeaderPaneEnvState::Unknown => None,
|
|
807
|
+
};
|
|
808
|
+
let Some(reason) = failure else {
|
|
809
|
+
return Ok(());
|
|
810
|
+
};
|
|
786
811
|
Err(LifecycleError::RequirementUnmet(format!(
|
|
787
|
-
"TEAM_AGENT_LEADER_PANE_ID points at a
|
|
812
|
+
"TEAM_AGENT_LEADER_PANE_ID points at a {reason} pane: {pane_id_raw}\n\
|
|
788
813
|
action: unset TEAM_AGENT_LEADER_PANE_ID, or set it to a live tmux pane id\n\
|
|
789
814
|
log: TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}"
|
|
790
815
|
)))
|
|
791
816
|
}
|
|
792
817
|
|
|
818
|
+
fn write_invalid_leader_pane_env_warning(workspaces: &[&Path], pane_id_raw: &str) {
|
|
819
|
+
let message = "invalid pane id format, skipping validation";
|
|
820
|
+
let mut wrote = false;
|
|
821
|
+
let mut errors = Vec::new();
|
|
822
|
+
let mut seen = BTreeSet::new();
|
|
823
|
+
for workspace in workspaces {
|
|
824
|
+
let key = workspace.to_string_lossy().to_string();
|
|
825
|
+
if !seen.insert(key.clone()) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
match crate::event_log::EventLog::new(workspace).write(
|
|
829
|
+
"leader_pane_env.validation_warning",
|
|
830
|
+
serde_json::json!({
|
|
831
|
+
"env": "TEAM_AGENT_LEADER_PANE_ID",
|
|
832
|
+
"value": pane_id_raw,
|
|
833
|
+
"warning": message,
|
|
834
|
+
}),
|
|
835
|
+
) {
|
|
836
|
+
Ok(_) => wrote = true,
|
|
837
|
+
Err(err) => errors.push(format!("{key}: {err}")),
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if !wrote {
|
|
841
|
+
eprintln!("TEAM_AGENT_LEADER_PANE_ID={pane_id_raw}: {message}");
|
|
842
|
+
if !errors.is_empty() {
|
|
843
|
+
eprintln!(
|
|
844
|
+
"TEAM_AGENT_LEADER_PANE_ID warning event write failed: {}",
|
|
845
|
+
errors.join("; ")
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
852
|
+
pub(crate) enum LeaderPaneEnvState {
|
|
853
|
+
Live,
|
|
854
|
+
Dead,
|
|
855
|
+
Absent,
|
|
856
|
+
Unknown,
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
fn leader_pane_env_state_for_validation(
|
|
860
|
+
transport: &dyn Transport,
|
|
861
|
+
pane: &crate::transport::PaneId,
|
|
862
|
+
) -> LeaderPaneEnvState {
|
|
863
|
+
if !is_tmux_pane_id_format(pane) {
|
|
864
|
+
return LeaderPaneEnvState::Unknown;
|
|
865
|
+
}
|
|
866
|
+
if transport.probes_real_tmux_socket_roots() {
|
|
867
|
+
return active_leader_pane_state_across_tmux_sockets(pane);
|
|
868
|
+
}
|
|
869
|
+
active_leader_pane_state(transport, pane)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
fn is_tmux_pane_id_format(pane: &crate::transport::PaneId) -> bool {
|
|
873
|
+
let pane = pane.as_str();
|
|
874
|
+
pane.len() > 1 && pane.starts_with('%') && pane[1..].chars().all(|ch| ch.is_ascii_digit())
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
fn active_leader_pane_state_across_tmux_sockets(
|
|
878
|
+
pane: &crate::transport::PaneId,
|
|
879
|
+
) -> LeaderPaneEnvState {
|
|
880
|
+
let endpoints = crate::tmux_backend::tmux_socket_endpoints();
|
|
881
|
+
let transports = endpoints
|
|
882
|
+
.iter()
|
|
883
|
+
.map(|endpoint| crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint))
|
|
884
|
+
.collect::<Vec<_>>();
|
|
885
|
+
active_leader_pane_state_across_transports(
|
|
886
|
+
transports.iter().map(|transport| transport as &dyn Transport),
|
|
887
|
+
pane,
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
pub(crate) fn active_leader_pane_state_across_transports<'a>(
|
|
892
|
+
transports: impl IntoIterator<Item = &'a dyn Transport>,
|
|
893
|
+
pane: &crate::transport::PaneId,
|
|
894
|
+
) -> LeaderPaneEnvState {
|
|
895
|
+
let mut found_absent = false;
|
|
896
|
+
let mut found_dead = false;
|
|
897
|
+
for transport in transports {
|
|
898
|
+
match active_leader_pane_state(transport, pane) {
|
|
899
|
+
LeaderPaneEnvState::Live => return LeaderPaneEnvState::Live,
|
|
900
|
+
LeaderPaneEnvState::Dead => found_dead = true,
|
|
901
|
+
LeaderPaneEnvState::Absent => found_absent = true,
|
|
902
|
+
LeaderPaneEnvState::Unknown => {}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if found_dead {
|
|
906
|
+
LeaderPaneEnvState::Dead
|
|
907
|
+
} else if found_absent {
|
|
908
|
+
LeaderPaneEnvState::Absent
|
|
909
|
+
} else {
|
|
910
|
+
LeaderPaneEnvState::Unknown
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
fn active_leader_pane_state(
|
|
915
|
+
transport: &dyn Transport,
|
|
916
|
+
pane: &crate::transport::PaneId,
|
|
917
|
+
) -> LeaderPaneEnvState {
|
|
918
|
+
match transport.has_pane(pane) {
|
|
919
|
+
Ok(Some(true)) => return LeaderPaneEnvState::Live,
|
|
920
|
+
Ok(Some(false)) => return LeaderPaneEnvState::Absent,
|
|
921
|
+
Ok(None) | Err(_) => {}
|
|
922
|
+
}
|
|
923
|
+
match transport.liveness(pane) {
|
|
924
|
+
Ok(crate::transport::PaneLiveness::Live) => LeaderPaneEnvState::Live,
|
|
925
|
+
Ok(crate::transport::PaneLiveness::Dead) => LeaderPaneEnvState::Dead,
|
|
926
|
+
Ok(crate::transport::PaneLiveness::Unknown) | Err(_) => LeaderPaneEnvState::Unknown,
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
793
930
|
fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
|
|
794
931
|
let Some(owner) = unbound_launched_owner(launched, launched_key) else {
|
|
795
932
|
return;
|
|
@@ -1884,7 +2021,9 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1884
2021
|
// owner_bind / lease / display 任一消费点。被动路径(display/seed 等)各自走
|
|
1885
2022
|
// 降级+event,不在这里挡。错误三行式:error(含 pane id 字面)/action(unset
|
|
1886
2023
|
// 或修 env)/log(env var 名)。
|
|
1887
|
-
|
|
2024
|
+
let team_workspace = team_workspace(agents_dir);
|
|
2025
|
+
let warning_workspaces = [workspace, team_workspace.as_path()];
|
|
2026
|
+
validate_active_leader_pane_env_with_workspaces(transport, &warning_workspaces)?;
|
|
1888
2027
|
if !agents_dir.exists() {
|
|
1889
2028
|
return Err(LifecycleError::Compile(format!(
|
|
1890
2029
|
"agents dir not found: {}",
|
|
@@ -1920,18 +2059,40 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
1920
2059
|
.as_deref()
|
|
1921
2060
|
.is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
|
|
1922
2061
|
{
|
|
2062
|
+
let session_name = state
|
|
2063
|
+
.get("session_name")
|
|
2064
|
+
.and_then(serde_json::Value::as_str)
|
|
2065
|
+
.filter(|s| !s.is_empty())
|
|
2066
|
+
.map(SessionName::new);
|
|
2067
|
+
let attach_commands = session_name
|
|
2068
|
+
.as_ref()
|
|
2069
|
+
.map(|session| {
|
|
2070
|
+
let windows = quick_start_attach_window_names(&state);
|
|
2071
|
+
crate::tmux_backend::attach_commands_for_windows(
|
|
2072
|
+
&workspace,
|
|
2073
|
+
session,
|
|
2074
|
+
windows.iter().map(String::as_str),
|
|
2075
|
+
)
|
|
2076
|
+
})
|
|
2077
|
+
.unwrap_or_default();
|
|
2078
|
+
let mut next_actions = vec![
|
|
2079
|
+
"run restart to resume the existing team or pass --fresh to replace it"
|
|
2080
|
+
.to_string(),
|
|
2081
|
+
];
|
|
2082
|
+
if session_name.is_some() {
|
|
2083
|
+
if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
|
|
2084
|
+
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(
|
|
2085
|
+
&workspace,
|
|
2086
|
+
));
|
|
2087
|
+
}
|
|
2088
|
+
next_actions.extend(attach_commands.iter().cloned());
|
|
2089
|
+
}
|
|
1923
2090
|
return Ok(QuickStartReport::ExistingRuntime {
|
|
1924
2091
|
team: requested_team.clone(),
|
|
1925
|
-
session_name
|
|
1926
|
-
.get("session_name")
|
|
1927
|
-
.and_then(serde_json::Value::as_str)
|
|
1928
|
-
.filter(|s| !s.is_empty())
|
|
1929
|
-
.map(SessionName::new),
|
|
2092
|
+
session_name,
|
|
1930
2093
|
state_path: Some(state_path),
|
|
1931
|
-
next_actions
|
|
1932
|
-
|
|
1933
|
-
.to_string(),
|
|
1934
|
-
],
|
|
2094
|
+
next_actions,
|
|
2095
|
+
attach_commands,
|
|
1935
2096
|
});
|
|
1936
2097
|
}
|
|
1937
2098
|
}
|
|
@@ -2008,6 +2169,9 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
2008
2169
|
let mut next_actions = vec![format!(
|
|
2009
2170
|
"team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
|
|
2010
2171
|
)];
|
|
2172
|
+
if crate::tmux_backend::socket_probe_missing_for_workspace(&workspace) {
|
|
2173
|
+
next_actions.push(crate::tmux_backend::socket_missing_hint_for_workspace(&workspace));
|
|
2174
|
+
}
|
|
2011
2175
|
next_actions.extend(attach_commands.iter().cloned());
|
|
2012
2176
|
let display_backend = state
|
|
2013
2177
|
.get("display_backend")
|
|
@@ -2024,6 +2188,29 @@ pub fn quick_start_with_transport_in_workspace(
|
|
|
2024
2188
|
})
|
|
2025
2189
|
}
|
|
2026
2190
|
|
|
2191
|
+
fn quick_start_attach_window_names(state: &serde_json::Value) -> Vec<String> {
|
|
2192
|
+
let mut windows = state
|
|
2193
|
+
.get("agents")
|
|
2194
|
+
.and_then(serde_json::Value::as_object)
|
|
2195
|
+
.map(|agents| {
|
|
2196
|
+
agents
|
|
2197
|
+
.iter()
|
|
2198
|
+
.filter_map(|(agent_id, agent)| {
|
|
2199
|
+
agent
|
|
2200
|
+
.get("window")
|
|
2201
|
+
.and_then(serde_json::Value::as_str)
|
|
2202
|
+
.filter(|window| !window.is_empty())
|
|
2203
|
+
.map(str::to_string)
|
|
2204
|
+
.or_else(|| Some(agent_id.clone()))
|
|
2205
|
+
})
|
|
2206
|
+
.collect::<Vec<_>>()
|
|
2207
|
+
})
|
|
2208
|
+
.unwrap_or_default();
|
|
2209
|
+
windows.sort();
|
|
2210
|
+
windows.dedup();
|
|
2211
|
+
windows
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2027
2214
|
/// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
|
|
2028
2215
|
/// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
|
|
2029
2216
|
/// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
|
|
@@ -77,28 +77,41 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
77
77
|
transport: &dyn crate::transport::Transport,
|
|
78
78
|
session_converge_deadline_ms: Option<u64>,
|
|
79
79
|
) -> Result<RestartReport, LifecycleError> {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
let run_candidate = crate::model::paths::canonical_run_workspace(workspace)
|
|
80
|
+
// RED-2-STILL(P0):入口门必须在 canonical_run_workspace 解析后的路径上判,不用 raw workspace。
|
|
81
|
+
// 根因:quick-start <dir> 把 .team/runtime/spec 落在 team_workspace(dir)=**parent**/.team;
|
|
82
|
+
// 入口门查 raw dir 自身的 .team/state(空,它在 parent)→ 误判"无 team context"早退,到不了
|
|
83
|
+
// 067f78f 下移后的第二道门。canonical_run_workspace 已能正确解析到 parent(走 parent.join(".team")
|
|
84
|
+
// 分支),在它之上判 input_has_no_local_team_context 才对齐 quick-start 落点。
|
|
85
|
+
let resolved_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
87
86
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
88
|
-
if
|
|
89
|
-
&& !crate::state::persist::runtime_state_path(&run_candidate).exists()
|
|
90
|
-
{
|
|
87
|
+
if crate::lifecycle::restart::input_has_no_local_team_context(&resolved_ws) {
|
|
91
88
|
return Err(LifecycleError::TeamSelect(format!(
|
|
92
|
-
"missing spec for restart: {}",
|
|
93
|
-
|
|
89
|
+
"missing spec for restart: {} (run `team-agent quick-start <teamdir>` first)",
|
|
90
|
+
crate::model::paths::runtime_dir(&resolved_ws).display()
|
|
94
91
|
)));
|
|
95
92
|
}
|
|
93
|
+
// RED-2(P0)修:存在性门下移到 resolve 之后,用 selected.spec_path(读序 B:runtime 优先、
|
|
94
|
+
// legacy 用户目录回落)判,而非 resolve 前自拼 workspace/team.spec.yaml(spec-demote 后 spec
|
|
95
|
+
// 不在用户目录 → 旧门误报缺)。resolve_active_team(RequireSpec) 内部已按 spec_path 校验存在性,
|
|
96
|
+
// 故直接交给它;失败信息(含真实 expected runtime spec 路径)即正确的 N38。
|
|
96
97
|
let selected = crate::state::selector::resolve_active_team(
|
|
97
98
|
workspace,
|
|
98
99
|
team,
|
|
99
100
|
crate::state::selector::SelectorMode::RequireSpec,
|
|
100
101
|
)
|
|
101
102
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
103
|
+
// 显式存在性门(下移后):selected.spec_path 经读序 B 已定位 runtime/legacy spec。
|
|
104
|
+
// 缺(空目录 restart 等)→ 报真实期望路径,不误导去用户目录找。
|
|
105
|
+
if !selected.spec_path.as_ref().is_some_and(|p| p.exists()) {
|
|
106
|
+
let expected = selected
|
|
107
|
+
.spec_path
|
|
108
|
+
.clone()
|
|
109
|
+
.unwrap_or_else(|| crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key));
|
|
110
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
111
|
+
"missing spec for restart: {} (run `team-agent quick-start <teamdir>` first, or restore the team's role docs)",
|
|
112
|
+
expected.display()
|
|
113
|
+
)));
|
|
114
|
+
}
|
|
102
115
|
let mut state = selected.state;
|
|
103
116
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
|
|
104
117
|
// E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
|
|
@@ -49,11 +49,15 @@ pub(crate) fn lifecycle_run_workspace(workspace: &Path) -> Result<std::path::Pat
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
fn lifecycle_paths(workspace: &Path, team: Option<&str>) -> Result<LifecyclePaths, LifecycleError> {
|
|
52
|
-
|
|
52
|
+
// RED-2-STILL(P0):入口门在 canonical_run_workspace 解析后的路径上判(quick-start 的 .team 落
|
|
53
|
+
// team_dir 父目录,raw team_dir 必 miss)。期望路径报解析后 runtime 落点,不指 raw team_dir。
|
|
54
|
+
let resolved_ws = crate::model::paths::canonical_run_workspace(workspace)
|
|
55
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
56
|
+
if input_has_no_local_team_context(&resolved_ws) {
|
|
53
57
|
return Err(LifecycleError::TeamSelect(format!(
|
|
54
|
-
"active team spec not found: input_workspace={}
|
|
58
|
+
"active team spec not found: input_workspace={} expected_runtime_dir={}",
|
|
55
59
|
workspace.display(),
|
|
56
|
-
|
|
60
|
+
crate::model::paths::runtime_dir(&resolved_ws).display()
|
|
57
61
|
)));
|
|
58
62
|
}
|
|
59
63
|
let selected = crate::state::selector::resolve_active_team(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
use super::*;
|
|
2
|
+
use crate::transport::PaneLiveness;
|
|
2
3
|
|
|
3
4
|
// ───────────────────────────────────────────────────────────────────────
|
|
4
5
|
// classify_first_send_at — _classify_first_send_at (orchestration.py:404)
|
|
@@ -794,6 +795,189 @@ fn detect_dangerous_approval_clean_process_is_disabled() {
|
|
|
794
795
|
assert!(!got.inherited);
|
|
795
796
|
}
|
|
796
797
|
|
|
798
|
+
#[test]
|
|
799
|
+
#[serial_test::serial(env)]
|
|
800
|
+
fn leader_pane_env_absent_pane_fails_fast() {
|
|
801
|
+
let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "%9999");
|
|
802
|
+
let transport =
|
|
803
|
+
crate::transport::test_support::OfflineTransport::new().with_pane_presence("%9999", false);
|
|
804
|
+
let err = validate_active_leader_pane_env(&transport).expect_err("absent pane must fail");
|
|
805
|
+
let msg = err.to_string();
|
|
806
|
+
assert!(msg.contains("TEAM_AGENT_LEADER_PANE_ID"), "got: {msg}");
|
|
807
|
+
assert!(msg.contains("absent"), "got: {msg}");
|
|
808
|
+
assert!(msg.contains("%9999"), "got: {msg}");
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
#[test]
|
|
812
|
+
#[serial_test::serial(env)]
|
|
813
|
+
fn leader_pane_env_dead_pane_fails_fast() {
|
|
814
|
+
let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "%404");
|
|
815
|
+
let transport = crate::transport::test_support::OfflineTransport::new()
|
|
816
|
+
.with_liveness("%404", PaneLiveness::Dead);
|
|
817
|
+
let err = validate_active_leader_pane_env(&transport).expect_err("dead pane must fail");
|
|
818
|
+
let msg = err.to_string();
|
|
819
|
+
assert!(msg.contains("TEAM_AGENT_LEADER_PANE_ID"), "got: {msg}");
|
|
820
|
+
assert!(msg.contains("dead"), "got: {msg}");
|
|
821
|
+
assert!(msg.contains("%404"), "got: {msg}");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
#[test]
|
|
825
|
+
#[serial_test::serial(env)]
|
|
826
|
+
fn leader_pane_env_unset_passes_without_probe() {
|
|
827
|
+
let _leader_pane = EnvVarGuard::remove("TEAM_AGENT_LEADER_PANE_ID");
|
|
828
|
+
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
829
|
+
validate_active_leader_pane_env(&transport).expect("unset env must pass");
|
|
830
|
+
assert!(
|
|
831
|
+
transport.calls().is_empty(),
|
|
832
|
+
"unset env must not probe transport: {:?}",
|
|
833
|
+
transport.calls()
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
#[test]
|
|
838
|
+
#[serial_test::serial(env)]
|
|
839
|
+
fn leader_pane_env_unknown_when_unprovable_does_not_fail_fast() {
|
|
840
|
+
let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "%8888");
|
|
841
|
+
let transport = crate::transport::test_support::OfflineTransport::new()
|
|
842
|
+
.with_default_liveness(PaneLiveness::Unknown);
|
|
843
|
+
validate_active_leader_pane_env(&transport).expect("unknown/unprovable pane must not fail");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#[test]
|
|
847
|
+
#[serial_test::serial(env)]
|
|
848
|
+
fn leader_pane_env_non_numeric_pane_id_is_unknown_not_absent() {
|
|
849
|
+
let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "abc-notapane");
|
|
850
|
+
let transport = crate::transport::test_support::OfflineTransport::new()
|
|
851
|
+
.with_pane_presence("abc-notapane", false)
|
|
852
|
+
.with_liveness("abc-notapane", PaneLiveness::Dead);
|
|
853
|
+
validate_active_leader_pane_env(&transport)
|
|
854
|
+
.expect("non `%<digits>` env values are unprovable, not absent");
|
|
855
|
+
assert!(
|
|
856
|
+
transport.calls().is_empty(),
|
|
857
|
+
"invalid pane id format must not hit tmux probe: {:?}",
|
|
858
|
+
transport.calls()
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
#[test]
|
|
863
|
+
#[serial_test::serial(env)]
|
|
864
|
+
fn leader_pane_env_invalid_format_writes_warning_event() {
|
|
865
|
+
let ws = temp_ws();
|
|
866
|
+
let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "abc-notapane");
|
|
867
|
+
let transport = crate::transport::test_support::OfflineTransport::new()
|
|
868
|
+
.with_pane_presence("abc-notapane", false);
|
|
869
|
+
|
|
870
|
+
validate_active_leader_pane_env_with_workspace(&transport, Some(&ws))
|
|
871
|
+
.expect("invalid format is warning-only");
|
|
872
|
+
|
|
873
|
+
let events = crate::event_log::EventLog::new(&ws).tail(0).expect("events");
|
|
874
|
+
let event = events
|
|
875
|
+
.iter()
|
|
876
|
+
.find(|event| {
|
|
877
|
+
event.get("event").and_then(serde_json::Value::as_str)
|
|
878
|
+
== Some("leader_pane_env.validation_warning")
|
|
879
|
+
})
|
|
880
|
+
.expect("warning event");
|
|
881
|
+
assert_eq!(
|
|
882
|
+
event.get("value").and_then(serde_json::Value::as_str),
|
|
883
|
+
Some("abc-notapane")
|
|
884
|
+
);
|
|
885
|
+
assert_eq!(
|
|
886
|
+
event.get("warning").and_then(serde_json::Value::as_str),
|
|
887
|
+
Some("invalid pane id format, skipping validation")
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
#[test]
|
|
892
|
+
#[serial_test::serial(env)]
|
|
893
|
+
fn leader_pane_env_invalid_format_writes_warning_to_all_candidate_workspaces() {
|
|
894
|
+
let cli_ws = temp_ws();
|
|
895
|
+
let team_ws = temp_ws();
|
|
896
|
+
let _leader_pane = EnvVarGuard::set("TEAM_AGENT_LEADER_PANE_ID", "abc-notapane");
|
|
897
|
+
let transport = crate::transport::test_support::OfflineTransport::new();
|
|
898
|
+
|
|
899
|
+
validate_active_leader_pane_env_with_workspaces(&transport, &[&cli_ws, &team_ws])
|
|
900
|
+
.expect("invalid format is warning-only");
|
|
901
|
+
|
|
902
|
+
for ws in [&cli_ws, &team_ws] {
|
|
903
|
+
let events = crate::event_log::EventLog::new(ws).tail(0).expect("events");
|
|
904
|
+
assert!(
|
|
905
|
+
events.iter().any(|event| {
|
|
906
|
+
event.get("event").and_then(serde_json::Value::as_str)
|
|
907
|
+
== Some("leader_pane_env.validation_warning")
|
|
908
|
+
&& event.get("value").and_then(serde_json::Value::as_str)
|
|
909
|
+
== Some("abc-notapane")
|
|
910
|
+
}),
|
|
911
|
+
"warning event missing in {}: {events:?}",
|
|
912
|
+
ws.display()
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
#[test]
|
|
918
|
+
fn leader_pane_env_cross_socket_live_wins() {
|
|
919
|
+
let pane = crate::transport::PaneId::new("%7");
|
|
920
|
+
let absent =
|
|
921
|
+
crate::transport::test_support::OfflineTransport::new().with_pane_presence("%7", false);
|
|
922
|
+
let live =
|
|
923
|
+
crate::transport::test_support::OfflineTransport::new().with_pane_presence("%7", true);
|
|
924
|
+
|
|
925
|
+
let state = active_leader_pane_state_across_transports(
|
|
926
|
+
[&absent as &dyn crate::transport::Transport, &live],
|
|
927
|
+
&pane,
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
assert_eq!(state, LeaderPaneEnvState::Live);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
#[test]
|
|
934
|
+
fn leader_pane_env_cross_socket_dead_rejects_without_live() {
|
|
935
|
+
let pane = crate::transport::PaneId::new("%404");
|
|
936
|
+
let absent = crate::transport::test_support::OfflineTransport::new()
|
|
937
|
+
.with_pane_presence("%404", false);
|
|
938
|
+
let dead = crate::transport::test_support::OfflineTransport::new()
|
|
939
|
+
.with_liveness("%404", PaneLiveness::Dead);
|
|
940
|
+
|
|
941
|
+
let state = active_leader_pane_state_across_transports(
|
|
942
|
+
[&absent as &dyn crate::transport::Transport, &dead],
|
|
943
|
+
&pane,
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
assert_eq!(state, LeaderPaneEnvState::Dead);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
#[test]
|
|
950
|
+
fn leader_pane_env_cross_socket_absent_when_reachable_servers_confirm_missing() {
|
|
951
|
+
let pane = crate::transport::PaneId::new("%9999");
|
|
952
|
+
let absent = crate::transport::test_support::OfflineTransport::new()
|
|
953
|
+
.with_pane_presence("%9999", false);
|
|
954
|
+
let unknown = crate::transport::test_support::OfflineTransport::new()
|
|
955
|
+
.with_default_liveness(PaneLiveness::Unknown);
|
|
956
|
+
|
|
957
|
+
let state = active_leader_pane_state_across_transports(
|
|
958
|
+
[&absent as &dyn crate::transport::Transport, &unknown],
|
|
959
|
+
&pane,
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
assert_eq!(state, LeaderPaneEnvState::Absent);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
#[test]
|
|
966
|
+
fn leader_pane_env_cross_socket_all_probe_errors_stays_unknown() {
|
|
967
|
+
let pane = crate::transport::PaneId::new("%maybe");
|
|
968
|
+
let unknown_a = crate::transport::test_support::OfflineTransport::new()
|
|
969
|
+
.with_default_liveness(PaneLiveness::Unknown);
|
|
970
|
+
let unknown_b = crate::transport::test_support::OfflineTransport::new()
|
|
971
|
+
.with_default_liveness(PaneLiveness::Unknown);
|
|
972
|
+
|
|
973
|
+
let state = active_leader_pane_state_across_transports(
|
|
974
|
+
[&unknown_a as &dyn crate::transport::Transport, &unknown_b],
|
|
975
|
+
&pane,
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
assert_eq!(state, LeaderPaneEnvState::Unknown);
|
|
979
|
+
}
|
|
980
|
+
|
|
797
981
|
struct EnvVarGuard {
|
|
798
982
|
key: &'static str,
|
|
799
983
|
previous: Option<String>,
|
|
@@ -807,6 +991,14 @@ impl EnvVarGuard {
|
|
|
807
991
|
}
|
|
808
992
|
Self { key, previous }
|
|
809
993
|
}
|
|
994
|
+
|
|
995
|
+
fn remove(key: &'static str) -> Self {
|
|
996
|
+
let previous = std::env::var(key).ok();
|
|
997
|
+
unsafe {
|
|
998
|
+
std::env::remove_var(key);
|
|
999
|
+
}
|
|
1000
|
+
Self { key, previous }
|
|
1001
|
+
}
|
|
810
1002
|
}
|
|
811
1003
|
|
|
812
1004
|
impl Drop for EnvVarGuard {
|
|
@@ -545,6 +545,118 @@ fn e5_restart_rebuilds_runtime_spec_from_role_docs() {
|
|
|
545
545
|
);
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
// RED-2 (P0) — restart existence gate must use selected.spec_path (read-order B: runtime spec),
|
|
549
|
+
// NOT the user-dir team.spec.yaml. spec-demote put spec under .team/runtime/<key>/, so the old
|
|
550
|
+
// pre-resolve gate (`!workspace.join("team.spec.yaml").exists()`) falsely reported "missing spec".
|
|
551
|
+
#[test]
|
|
552
|
+
fn red2_restart_finds_runtime_spec_not_missing_when_user_dir_has_no_spec() {
|
|
553
|
+
let ws = restart_ws_two_resumable_workers(); // has TEAM.md + agents + state
|
|
554
|
+
// Simulate spec-demote: remove any user-dir spec the fixture wrote; restart must still find
|
|
555
|
+
// the runtime spec (rebuilt from role docs / read-order B), NOT report "missing spec".
|
|
556
|
+
let _ = std::fs::remove_file(ws.join("team.spec.yaml"));
|
|
557
|
+
assert!(!ws.join("team.spec.yaml").exists(), "user-dir spec removed (spec-demote condition)");
|
|
558
|
+
let transport = OfflineTransport::new();
|
|
559
|
+
let result = restart_with_transport(&ws, false, None, &transport);
|
|
560
|
+
// The gate must NOT short-circuit with "missing spec for restart" — restart must proceed
|
|
561
|
+
// (any later resume/rebuild outcome is fine; we only assert the spec-missing gate is gone).
|
|
562
|
+
let text = format!("{result:?}");
|
|
563
|
+
assert!(
|
|
564
|
+
!text.contains("missing spec for restart"),
|
|
565
|
+
"RED-2: restart must locate the runtime spec via read-order B, not report missing; got {text}"
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// RED-2 negative — a truly empty workspace (no role docs, no spec, no state) must STILL report
|
|
570
|
+
// missing spec (gate moved, not removed).
|
|
571
|
+
#[test]
|
|
572
|
+
fn red2_restart_empty_workspace_still_reports_missing() {
|
|
573
|
+
let ws = temp_ws(); // empty, no TEAM.md/agents/spec/state
|
|
574
|
+
let transport = OfflineTransport::new();
|
|
575
|
+
let result = restart_with_transport(&ws, false, None, &transport);
|
|
576
|
+
let text = format!("{result:?}");
|
|
577
|
+
assert!(
|
|
578
|
+
text.contains("missing spec") || text.to_lowercase().contains("not found") || text.contains("no_local_team_context") || text.to_lowercase().contains("invalid workspace"),
|
|
579
|
+
"RED-2 negative: empty workspace restart must still error (missing/not-found); got {text}"
|
|
580
|
+
);
|
|
581
|
+
let _ = std::fs::remove_dir_all(&ws);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// RED-2-STILL (P0) — the ENTRY gate (input_has_no_local_team_context) must judge on the
|
|
585
|
+
// canonical_run_workspace-resolved path, not raw. quick-start <team_dir> lands .team under the
|
|
586
|
+
// resolved workspace (team_dir's parent when invoked from there); restart <team_dir> with raw gate
|
|
587
|
+
// checks team_dir's own (empty) .team → false "no context" early-exit before the down-moved gate.
|
|
588
|
+
//
|
|
589
|
+
// Two forms (leader hard requirement — must lock BOTH, not the coincidental same-dir form):
|
|
590
|
+
// Form A: .team in the team_dir's PARENT (standard quick-start <dir> from parent cwd).
|
|
591
|
+
// Form B: .team in the team_dir itself (cwd == team_dir).
|
|
592
|
+
#[test]
|
|
593
|
+
fn red2still_entry_gate_resolves_parent_dot_team_form_a() {
|
|
594
|
+
// Build: base/ (has .team) + base/teamdir/{TEAM.md, agents/} (role docs, NO own .team).
|
|
595
|
+
let base = temp_ws();
|
|
596
|
+
let team_dir = base.join("teamdir");
|
|
597
|
+
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
598
|
+
std::fs::write(team_dir.join("TEAM.md"), "---\nname: teamdir\nobjective: o\nprovider: codex\n---\nt.\n").unwrap();
|
|
599
|
+
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
600
|
+
// .team lives in the PARENT (base), with a runtime spec — mirrors quick-start <dir> from base cwd.
|
|
601
|
+
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
602
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&base, "teamdir");
|
|
603
|
+
std::fs::create_dir_all(runtime_spec.parent().unwrap()).unwrap();
|
|
604
|
+
std::fs::write(&runtime_spec, crate::model::yaml::dumps(&spec)).unwrap();
|
|
605
|
+
// Seed minimal team state under base so resolve finds it.
|
|
606
|
+
crate::state::persist::save_runtime_state(
|
|
607
|
+
&base,
|
|
608
|
+
&json!({"session_name": "team-teamdir", "team_dir": team_dir.to_string_lossy(),
|
|
609
|
+
"spec_path": runtime_spec.to_string_lossy(),
|
|
610
|
+
"agents": {"w1": {"status": "running", "provider": "codex"}}}),
|
|
611
|
+
)
|
|
612
|
+
.unwrap();
|
|
613
|
+
// Entry gate on team_dir: raw team_dir has NO .team (it's in parent), but resolved → base has it.
|
|
614
|
+
assert!(
|
|
615
|
+
crate::lifecycle::restart::input_has_no_local_team_context(&team_dir),
|
|
616
|
+
"precondition: raw team_dir has no local .team (it's in the parent)"
|
|
617
|
+
);
|
|
618
|
+
let resolved = crate::model::paths::canonical_run_workspace(&team_dir).unwrap();
|
|
619
|
+
assert!(
|
|
620
|
+
!crate::lifecycle::restart::input_has_no_local_team_context(&resolved),
|
|
621
|
+
"RED-2-STILL: gate on canonical_run_workspace-resolved path must see parent .team (false)"
|
|
622
|
+
);
|
|
623
|
+
// Full restart on team_dir must NOT early-exit with missing spec.
|
|
624
|
+
let transport = OfflineTransport::new();
|
|
625
|
+
let text = format!("{:?}", restart_with_transport(&team_dir, false, None, &transport));
|
|
626
|
+
assert!(
|
|
627
|
+
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
628
|
+
"RED-2-STILL Form A: restart <team_dir> (.team in parent) must not report missing; got {text}"
|
|
629
|
+
);
|
|
630
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#[test]
|
|
634
|
+
fn red2still_entry_gate_same_dir_dot_team_form_b() {
|
|
635
|
+
// Form B: .team in team_dir itself (cwd == team_dir at quick-start time).
|
|
636
|
+
let team_dir = temp_ws();
|
|
637
|
+
std::fs::create_dir_all(team_dir.join("agents")).unwrap();
|
|
638
|
+
std::fs::write(team_dir.join("TEAM.md"), "---\nname: tb\nobjective: o\nprovider: codex\n---\nt.\n").unwrap();
|
|
639
|
+
std::fs::write(team_dir.join("agents").join("w1.md"), QS_VALID_ROLE).unwrap();
|
|
640
|
+
let spec = crate::compiler::compile_team(&team_dir).unwrap();
|
|
641
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&team_dir, "tb");
|
|
642
|
+
std::fs::create_dir_all(runtime_spec.parent().unwrap()).unwrap();
|
|
643
|
+
std::fs::write(&runtime_spec, crate::model::yaml::dumps(&spec)).unwrap();
|
|
644
|
+
crate::state::persist::save_runtime_state(
|
|
645
|
+
&team_dir,
|
|
646
|
+
&json!({"session_name": "team-tb", "team_dir": team_dir.to_string_lossy(),
|
|
647
|
+
"spec_path": runtime_spec.to_string_lossy(),
|
|
648
|
+
"agents": {"w1": {"status": "running", "provider": "codex"}}}),
|
|
649
|
+
)
|
|
650
|
+
.unwrap();
|
|
651
|
+
let transport = OfflineTransport::new();
|
|
652
|
+
let text = format!("{:?}", restart_with_transport(&team_dir, false, None, &transport));
|
|
653
|
+
assert!(
|
|
654
|
+
!text.contains("missing spec for restart") && !text.contains("active team spec not found"),
|
|
655
|
+
"RED-2-STILL Form B: restart <team_dir> (.team in team_dir) must not report missing; got {text}"
|
|
656
|
+
);
|
|
657
|
+
let _ = std::fs::remove_dir_all(&team_dir);
|
|
658
|
+
}
|
|
659
|
+
|
|
548
660
|
// E5 task#3 / RC-A6b — restart with role definitions MISSING explicitly refuses (lists what's
|
|
549
661
|
// missing) and leaves the previous runtime spec in place (no silent path, no data destruction).
|
|
550
662
|
#[test]
|