@team-agent/installer 0.3.6 → 0.3.8
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/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +175 -0
- package/crates/team-agent/src/cli/mod.rs +455 -63
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/base.rs +9 -4
- 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 +10 -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/cli/types.rs +3 -2
- package/crates/team-agent/src/compiler.rs +73 -50
- package/crates/team-agent/src/coordinator/tick.rs +108 -20
- package/crates/team-agent/src/db/migration.rs +17 -1
- package/crates/team-agent/src/leader/owner_bind.rs +59 -20
- package/crates/team-agent/src/lifecycle/launch.rs +378 -56
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +91 -12
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +238 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +257 -7
- package/crates/team-agent/src/lifecycle/types.rs +2 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
- package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
- package/crates/team-agent/src/mcp_server/tools.rs +25 -1
- package/crates/team-agent/src/mcp_server/wire.rs +11 -1
- package/crates/team-agent/src/model/paths.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +23 -1
- package/crates/team-agent/src/packaging/install.rs +42 -4
- package/crates/team-agent/src/packaging/tests.rs +91 -14
- package/crates/team-agent/src/packaging/types.rs +13 -1
- package/crates/team-agent/src/provider/adapter.rs +381 -15
- package/crates/team-agent/src/state/identity.rs +29 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
- package/crates/team-agent/src/tmux_backend.rs +104 -9
- 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
- package/skills/team-agent/SKILL.md +82 -5
|
@@ -83,28 +83,39 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
83
83
|
workspace.join("team.spec.yaml").display()
|
|
84
84
|
)));
|
|
85
85
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
return Err(LifecycleError::TeamSelect(format!(
|
|
92
|
-
"missing spec for restart: {}",
|
|
93
|
-
workspace.join("team.spec.yaml").display()
|
|
94
|
-
)));
|
|
95
|
-
}
|
|
86
|
+
// RED-2(P0)修:存在性门下移到 resolve 之后,用 selected.spec_path(读序 B:runtime 优先、
|
|
87
|
+
// legacy 用户目录回落)判,而非 resolve 前自拼 workspace/team.spec.yaml(spec-demote 后 spec
|
|
88
|
+
// 不在用户目录 → 旧门误报缺)。resolve_active_team(RequireSpec) 内部已按 spec_path 校验存在性,
|
|
89
|
+
// 故直接交给它;失败信息(含真实 expected runtime spec 路径)即正确的 N38。
|
|
96
90
|
let selected = crate::state::selector::resolve_active_team(
|
|
97
91
|
workspace,
|
|
98
92
|
team,
|
|
99
93
|
crate::state::selector::SelectorMode::RequireSpec,
|
|
100
94
|
)
|
|
101
95
|
.map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
|
|
96
|
+
// 显式存在性门(下移后):selected.spec_path 经读序 B 已定位 runtime/legacy spec。
|
|
97
|
+
// 缺(空目录 restart 等)→ 报真实期望路径,不误导去用户目录找。
|
|
98
|
+
if !selected.spec_path.as_ref().is_some_and(|p| p.exists()) {
|
|
99
|
+
let expected = selected
|
|
100
|
+
.spec_path
|
|
101
|
+
.clone()
|
|
102
|
+
.unwrap_or_else(|| crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key));
|
|
103
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
104
|
+
"missing spec for restart: {} (run `team-agent quick-start <teamdir>` first, or restore the team's role docs)",
|
|
105
|
+
expected.display()
|
|
106
|
+
)));
|
|
107
|
+
}
|
|
102
108
|
let mut state = selected.state;
|
|
103
109
|
crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
|
|
104
|
-
|
|
110
|
+
// E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
|
|
111
|
+
// 角色定义=第一真相源。角色齐 → compile_team 重建 + 保留运行期 override(session_name)+
|
|
112
|
+
// 写 runtime spec。角色缺(TEAM.md/agents 不在)→ 显式拒(列缺哪些),旧 spec 原地保留不删不用。
|
|
113
|
+
let spec = rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
|
|
114
|
+
// 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
|
|
115
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
|
|
116
|
+
let spec_workspace = runtime_spec.parent().ok_or_else(|| {
|
|
105
117
|
LifecycleError::TeamSelect("active team spec workspace not found".to_string())
|
|
106
118
|
})?;
|
|
107
|
-
let spec = load_team_spec(spec_workspace)?;
|
|
108
119
|
let safety = crate::lifecycle::launch::effective_runtime_config(&spec)?;
|
|
109
120
|
let mut convergence = converge_missing_provider_sessions(
|
|
110
121
|
&mut state,
|
|
@@ -713,6 +724,74 @@ fn restart_candidate_from_state(
|
|
|
713
724
|
}
|
|
714
725
|
}
|
|
715
726
|
|
|
727
|
+
/// E5 task#3 / RC-A6a:每次 restart 都以**角色定义**(team_dir 的 TEAM.md+agents/*.md)
|
|
728
|
+
/// compile_team 重建 runtime spec(覆盖),保留运行期 override(session_name 必须延续,
|
|
729
|
+
/// 否则 tmux session 对不上)。写到 .team/runtime/<team_key>/team.spec.yaml。
|
|
730
|
+
///
|
|
731
|
+
/// 角色定义缺(team_dir 未记 / TEAM.md 不在 / agents 不在)→ **显式拒**(LifecycleError,
|
|
732
|
+
/// CLI N38 三行式),列出缺哪些;**旧 spec 原地保留不删不用**(T2 防数据销毁,无静默路径)。
|
|
733
|
+
fn rebuild_runtime_spec_from_roles(
|
|
734
|
+
run_workspace: &Path,
|
|
735
|
+
team_key: &str,
|
|
736
|
+
state: &serde_json::Value,
|
|
737
|
+
) -> Result<YamlValue, LifecycleError> {
|
|
738
|
+
// team_dir(角色定义源)优先取 state.team_dir;缺则回落 run_workspace(自含 team-dir 布局,
|
|
739
|
+
// run_workspace 本身即角色目录)。两者都无角色定义则下面的齐全性检查会显式拒。
|
|
740
|
+
let team_dir = state
|
|
741
|
+
.get("team_dir")
|
|
742
|
+
.and_then(serde_json::Value::as_str)
|
|
743
|
+
.filter(|s| !s.is_empty())
|
|
744
|
+
.map(std::path::PathBuf::from)
|
|
745
|
+
.unwrap_or_else(|| run_workspace.to_path_buf());
|
|
746
|
+
// 角色定义齐全性检查(显式拒,列缺哪些;旧 spec 不动)。
|
|
747
|
+
let mut missing: Vec<String> = Vec::new();
|
|
748
|
+
if !team_dir.join("TEAM.md").exists() {
|
|
749
|
+
missing.push(format!("{}/TEAM.md", team_dir.display()));
|
|
750
|
+
}
|
|
751
|
+
let agents_dir = team_dir.join("agents");
|
|
752
|
+
let has_role_doc = std::fs::read_dir(&agents_dir)
|
|
753
|
+
.map(|entries| {
|
|
754
|
+
entries.flatten().any(|e| {
|
|
755
|
+
e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
|
756
|
+
})
|
|
757
|
+
})
|
|
758
|
+
.unwrap_or(false);
|
|
759
|
+
if !has_role_doc {
|
|
760
|
+
missing.push(format!("{}/*.md (at least one role doc)", agents_dir.display()));
|
|
761
|
+
}
|
|
762
|
+
if !missing.is_empty() {
|
|
763
|
+
// N38 三行式:error / action / log。旧 runtime spec 原地保留(不删不用)。
|
|
764
|
+
return Err(LifecycleError::TeamSelect(format!(
|
|
765
|
+
"cannot restart: role definitions missing for team '{team_key}': {}. \
|
|
766
|
+
action: restore the listed role docs (TEAM.md + agents/*.md are the source of truth), \
|
|
767
|
+
then re-run restart; the previous runtime spec is left in place (not used). \
|
|
768
|
+
log: team_dir={}",
|
|
769
|
+
missing.join(", "),
|
|
770
|
+
team_dir.display(),
|
|
771
|
+
)));
|
|
772
|
+
}
|
|
773
|
+
// 重建:compile_team(角色定义) + 保留运行期 session_name override。
|
|
774
|
+
let mut spec = crate::compiler::compile_team(&team_dir)
|
|
775
|
+
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
776
|
+
if let Some(session_name) = state
|
|
777
|
+
.get("session_name")
|
|
778
|
+
.and_then(serde_json::Value::as_str)
|
|
779
|
+
.filter(|s| !s.is_empty())
|
|
780
|
+
{
|
|
781
|
+
crate::lifecycle::launch::override_spec_session_name(&mut spec, session_name);
|
|
782
|
+
}
|
|
783
|
+
// 写 runtime spec(覆盖,原子 tmp+rename;Bug2)。
|
|
784
|
+
let spec_path = crate::model::paths::runtime_spec_path(run_workspace, team_key);
|
|
785
|
+
crate::lifecycle::launch::write_spec_atomic(&spec_path, &spec)?;
|
|
786
|
+
// RC-A6a:重建成功后清理用户目录的 legacy spec(中间产物不该留在角色目录)。
|
|
787
|
+
// 仅删 team_dir 下的 team.spec.yaml(角色定义 TEAM.md/agents 不动);失败不致命(best-effort)。
|
|
788
|
+
let legacy_spec = team_dir.join("team.spec.yaml");
|
|
789
|
+
if legacy_spec.exists() && legacy_spec != spec_path {
|
|
790
|
+
let _ = std::fs::remove_file(&legacy_spec);
|
|
791
|
+
}
|
|
792
|
+
Ok(spec)
|
|
793
|
+
}
|
|
794
|
+
|
|
716
795
|
fn restart_candidate_spec_path(workspace: &Path, state: &serde_json::Value) -> std::path::PathBuf {
|
|
717
796
|
if let Some(path) = state
|
|
718
797
|
.get("spec_path")
|
|
@@ -120,8 +120,8 @@ pub fn python_type_name(value: &serde_json::Value) -> &'static str {
|
|
|
120
120
|
/// `_collect_corrupt_first_send_at`,`orchestration.py:430/467`)。读 fixture state 的
|
|
121
121
|
/// `agents.<id>`,对每非 paused worker:
|
|
122
122
|
/// (1) corrupt first_send_at → 收进 `corrupt_entries`(carry python type-name);
|
|
123
|
-
/// (2) 算 resume 决策(`
|
|
124
|
-
///
|
|
123
|
+
/// (2) 算 resume 决策(`session_id→Resume` / `null session && allow_fresh→FreshStart` /
|
|
124
|
+
/// 否则 `Refuse`;E6 层2:null session 不再因 first_send_at=null 静默 fresh);
|
|
125
125
|
/// (3) `Refuse` 的 worker(reason=`no_persisted_session_id`(无 session)|`session_unresumable`)
|
|
126
126
|
/// 进 `unresumable`。
|
|
127
127
|
/// restart() **先**调它再 teardown;corrupt 非空 → `RefusedInvalidFirstSendAt`,unresumable
|
|
@@ -171,10 +171,12 @@ pub fn classify_restart_plan(
|
|
|
171
171
|
.and_then(|v| v.as_str())
|
|
172
172
|
.filter(|s| !s.is_empty())
|
|
173
173
|
.map(SessionId::new);
|
|
174
|
-
|
|
174
|
+
// E6 层2 (C2, 用户裁定"绝不静默 fresh"): null session 只有显式 --allow-fresh 才 fresh,
|
|
175
|
+
// 否则 Refuse(→ resume_not_ready + 指引)。删 `!interacted` 短路 —— 自启动 worker
|
|
176
|
+
// (leader 从未发消息 → first_send_at=null → interacted=false)会被它静默 fresh 丢上下文。
|
|
175
177
|
let decision = if session_id.is_some() {
|
|
176
178
|
ResumeDecision::Resume
|
|
177
|
-
} else if
|
|
179
|
+
} else if allow_fresh {
|
|
178
180
|
ResumeDecision::FreshStart
|
|
179
181
|
} else {
|
|
180
182
|
ResumeDecision::Refuse
|
|
@@ -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)
|
|
@@ -491,14 +492,57 @@ fn classify_restart_plan_interacted_unresumable_with_allow_fresh_yields_fresh_st
|
|
|
491
492
|
}
|
|
492
493
|
|
|
493
494
|
#[test]
|
|
494
|
-
fn
|
|
495
|
-
//
|
|
495
|
+
fn classify_restart_plan_never_interacted_null_session_refuses_not_fresh() {
|
|
496
|
+
// E6 层2 (C2, 用户裁定"绝不静默 fresh"): first_send_at absent(自启动 worker,leader 从未发消息)
|
|
497
|
+
// + session_id null → 不能再静默 FreshStart(那会丢真实 provider 会话上下文)。
|
|
498
|
+
// 默认 !allow_fresh → Refuse + unresumable(诚实出口 resume_not_ready)。
|
|
496
499
|
let state = json!({
|
|
497
500
|
"agents": { "w1": { "provider": "claude", "session_id": null } }
|
|
498
501
|
});
|
|
499
502
|
let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
|
|
500
503
|
assert_eq!(plan.decisions.len(), 1);
|
|
501
|
-
assert_eq!(
|
|
504
|
+
assert_eq!(
|
|
505
|
+
plan.decisions[0].decision,
|
|
506
|
+
ResumeDecision::Refuse,
|
|
507
|
+
"null-session 自启动 worker 默认必须 Refuse,不许静默 fresh"
|
|
508
|
+
);
|
|
509
|
+
assert_eq!(plan.unresumable.len(), 1, "Refuse 必入 unresumable(诚实出口)");
|
|
510
|
+
assert_eq!(plan.unresumable[0].reason, "no_persisted_session_id");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#[test]
|
|
514
|
+
fn classify_restart_plan_never_interacted_null_session_with_allow_fresh_marks_forced_fresh() {
|
|
515
|
+
// E6 层2: 同上自启动 null-session worker,但显式 --allow-fresh → 用户主动认账丢上下文 → FreshStart。
|
|
516
|
+
let state = json!({
|
|
517
|
+
"agents": { "w1": { "provider": "claude", "session_id": null } }
|
|
518
|
+
});
|
|
519
|
+
let plan = classify_restart_plan(&state, true).expect("纯验证不应 Err");
|
|
520
|
+
assert_eq!(plan.decisions.len(), 1);
|
|
521
|
+
assert_eq!(
|
|
522
|
+
plan.decisions[0].decision,
|
|
523
|
+
ResumeDecision::FreshStart,
|
|
524
|
+
"显式 --allow-fresh 才允许 null-session worker fresh"
|
|
525
|
+
);
|
|
526
|
+
assert!(
|
|
527
|
+
plan.unresumable.is_empty(),
|
|
528
|
+
"allow_fresh 下不触发 unresumable refusal"
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#[test]
|
|
533
|
+
fn classify_restart_plan_codex_with_session_still_resumes() {
|
|
534
|
+
// E6 层2 回归锁(不误伤): codex worker first_send_at=null 但 session_id 已捕 →
|
|
535
|
+
// 仍走 Resume(分流轴是 session_id 有无,不是 interacted)。防层2 修法把 has_session 也误判。
|
|
536
|
+
let state = json!({
|
|
537
|
+
"agents": { "w1": { "provider": "codex", "session_id": "sess-codex-abc" } }
|
|
538
|
+
});
|
|
539
|
+
let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
|
|
540
|
+
assert_eq!(plan.decisions.len(), 1);
|
|
541
|
+
assert_eq!(
|
|
542
|
+
plan.decisions[0].decision,
|
|
543
|
+
ResumeDecision::Resume,
|
|
544
|
+
"有 session_id 必 Resume,与 first_send_at/interacted 无关"
|
|
545
|
+
);
|
|
502
546
|
assert!(plan.unresumable.is_empty());
|
|
503
547
|
}
|
|
504
548
|
|
|
@@ -751,6 +795,189 @@ fn detect_dangerous_approval_clean_process_is_disabled() {
|
|
|
751
795
|
assert!(!got.inherited);
|
|
752
796
|
}
|
|
753
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
|
+
|
|
754
981
|
struct EnvVarGuard {
|
|
755
982
|
key: &'static str,
|
|
756
983
|
previous: Option<String>,
|
|
@@ -764,6 +991,14 @@ impl EnvVarGuard {
|
|
|
764
991
|
}
|
|
765
992
|
Self { key, previous }
|
|
766
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
|
+
}
|
|
767
1002
|
}
|
|
768
1003
|
|
|
769
1004
|
impl Drop for EnvVarGuard {
|