@team-agent/installer 0.3.7 → 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/emit.rs +112 -0
- package/crates/team-agent/src/cli/mod.rs +121 -28
- 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/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/lifecycle/launch.rs +203 -16
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +16 -10
- package/crates/team-agent/src/lifecycle/tests/core.rs +192 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +36 -0
- package/crates/team-agent/src/lifecycle/types.rs +2 -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
|
@@ -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,42 @@ 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
|
+
|
|
548
584
|
// E5 task#3 / RC-A6b — restart with role definitions MISSING explicitly refuses (lists what's
|
|
549
585
|
// missing) and leaves the previous runtime spec in place (no silent path, no data destruction).
|
|
550
586
|
#[test]
|
|
@@ -499,12 +499,14 @@ pub enum QuickStartReport {
|
|
|
499
499
|
session_name: Option<SessionName>,
|
|
500
500
|
state_path: Option<PathBuf>,
|
|
501
501
|
next_actions: Vec<String>,
|
|
502
|
+
attach_commands: Vec<String>,
|
|
502
503
|
},
|
|
503
504
|
/// preflight 阻塞(`quick_start.py:59`)。
|
|
504
505
|
PreflightBlocked {
|
|
505
506
|
summary: String,
|
|
506
507
|
blockers: Vec<String>,
|
|
507
508
|
next_actions: Vec<String>,
|
|
509
|
+
attach_commands: Vec<String>,
|
|
508
510
|
},
|
|
509
511
|
}
|
|
510
512
|
|
|
@@ -1118,30 +1118,51 @@ fn scan_copilot_session_store(context: &CaptureSessionContext) -> Vec<CapturedSe
|
|
|
1118
1118
|
) else {
|
|
1119
1119
|
return Vec::new();
|
|
1120
1120
|
};
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
)
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1121
|
+
// E11 层1(本机实锤):copilot honor `--session-id`(sessions.id == 注入的 expected_session_id),
|
|
1122
|
+
// 故 worker 权威 id 可靠在 db。**expected-id 优先点查**:命中即返(High,直接根治 leader/worker
|
|
1123
|
+
// 同 cwd 共享 db 时 latest-wins 误抓 leader 的 bug)。expected 查无 → **不 promote**(E6 铁律:
|
|
1124
|
+
// 不硬写不在盘的假 session),回落 cwd-latest 让收敛重试。
|
|
1125
|
+
if let Some(expected) = context.expected_session_id.as_ref() {
|
|
1126
|
+
let hit: Option<String> = conn
|
|
1127
|
+
.prepare("select id from sessions where id = ?1 limit 1")
|
|
1128
|
+
.ok()
|
|
1129
|
+
.and_then(|mut stmt| {
|
|
1130
|
+
stmt.query_row([expected.as_str()], |row| row.get::<_, String>(0)).ok()
|
|
1131
|
+
});
|
|
1132
|
+
if let Some(session_id) = hit {
|
|
1133
|
+
return vec![copilot_candidate(session_id, &db_path, context)];
|
|
1134
|
+
}
|
|
1135
|
+
// expected 设了但 db 无该 id → 返空(收敛重试),绝不回落抓别人(尤其 leader)的 latest。
|
|
1132
1136
|
return Vec::new();
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1137
|
+
}
|
|
1138
|
+
// 无 expected → **返空**(保守,不 cwd-latest 猜)。
|
|
1139
|
+
// E11 层1 兜底洞(architect 核实):leader+worker 同 cwd 共享 db 时,cwd-latest 可能抓 leader
|
|
1140
|
+
// 的 session;而 allocator 的 claimed 去重只扫 state.agents,**leader 在 state.leader/team_owner
|
|
1141
|
+
// 不在 agents → 兜不住**;且 leader 的 copilot session_id 运行期不入 state(team_owner 只存
|
|
1142
|
+
// leader_session_uuid,非 copilot db id),故无从显式排除。所幸 copilot build_command_plan **总**
|
|
1143
|
+
// 注入 --session-id(expected_session_id 恒 Some)→ 真实 copilot worker 永走上面点查路径,
|
|
1144
|
+
// 此 expected=None 分支对真实 worker 不可达。故直接返空最干净:不猜、绝不把 leader session 分给
|
|
1145
|
+
// worker。db 留 _。
|
|
1146
|
+
let _ = (&db_path, &conn);
|
|
1147
|
+
Vec::new()
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
fn copilot_candidate(
|
|
1151
|
+
session_id: String,
|
|
1152
|
+
db_path: &Path,
|
|
1153
|
+
context: &CaptureSessionContext,
|
|
1154
|
+
) -> CapturedSessionCandidate {
|
|
1155
|
+
CapturedSessionCandidate {
|
|
1135
1156
|
captured: CapturedSession {
|
|
1136
1157
|
session_id: Some(SessionId::new(session_id)),
|
|
1137
|
-
rollout_path: Some(RolloutPath::new(db_path.
|
|
1158
|
+
rollout_path: Some(RolloutPath::new(db_path.to_path_buf())),
|
|
1138
1159
|
captured_via: CaptureVia::FsWatch,
|
|
1139
1160
|
attribution_confidence: Confidence::High,
|
|
1140
1161
|
spawn_cwd: context.spawn_cwd.clone(),
|
|
1141
1162
|
},
|
|
1142
1163
|
positive_agent_id_match: false,
|
|
1143
1164
|
agent_path_match: false,
|
|
1144
|
-
}
|
|
1165
|
+
}
|
|
1145
1166
|
}
|
|
1146
1167
|
|
|
1147
1168
|
fn command_on_path(name: &str) -> bool {
|
|
@@ -1968,4 +1989,145 @@ mod e6_session_attribution_tests {
|
|
|
1968
1989
|
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
|
|
1969
1990
|
f.set_modified(when).unwrap();
|
|
1970
1991
|
}
|
|
1992
|
+
|
|
1993
|
+
// ── E11 层1:copilot session 归因(expected-id 优先,不抓 leader latest)──
|
|
1994
|
+
struct HomeGuard {
|
|
1995
|
+
prev: Option<std::ffi::OsString>,
|
|
1996
|
+
}
|
|
1997
|
+
impl HomeGuard {
|
|
1998
|
+
fn set(home: &Path) -> Self {
|
|
1999
|
+
let prev = std::env::var_os("HOME");
|
|
2000
|
+
std::env::set_var("HOME", home);
|
|
2001
|
+
Self { prev }
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
impl Drop for HomeGuard {
|
|
2005
|
+
fn drop(&mut self) {
|
|
2006
|
+
match &self.prev {
|
|
2007
|
+
Some(v) => std::env::set_var("HOME", v),
|
|
2008
|
+
None => std::env::remove_var("HOME"),
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/// 造一个 copilot session-store.db,写 (id, cwd, updated_at) 行。
|
|
2014
|
+
fn seed_copilot_db(home: &Path, rows: &[(&str, &str, i64)]) {
|
|
2015
|
+
let dir = home.join(".copilot");
|
|
2016
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
2017
|
+
let conn = rusqlite::Connection::open(dir.join("session-store.db")).unwrap();
|
|
2018
|
+
conn.execute(
|
|
2019
|
+
"create table sessions (id text primary key, cwd text, updated_at integer)",
|
|
2020
|
+
[],
|
|
2021
|
+
)
|
|
2022
|
+
.unwrap();
|
|
2023
|
+
for (id, cwd, updated) in rows {
|
|
2024
|
+
conn.execute(
|
|
2025
|
+
"insert into sessions (id, cwd, updated_at) values (?1, ?2, ?3)",
|
|
2026
|
+
rusqlite::params![id, cwd, updated],
|
|
2027
|
+
)
|
|
2028
|
+
.unwrap();
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
#[test]
|
|
2033
|
+
#[serial_test::serial(env)]
|
|
2034
|
+
fn copilot_expected_id_wins_over_leader_latest_same_cwd() {
|
|
2035
|
+
// 真机复现的确定性 fixture:leader row updated 晚(latest),worker row id == expected。
|
|
2036
|
+
// capture 必返 worker 自己的 id,不返 leader 的 latest。
|
|
2037
|
+
let base = tmp_root("e11-copilot");
|
|
2038
|
+
let home = base.join("home");
|
|
2039
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
2040
|
+
let cwd = base.join("ws");
|
|
2041
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
2042
|
+
let worker_id = "1142c4c2-0000-4000-8000-000000000001";
|
|
2043
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
2044
|
+
// leader updated_at 更大(latest-wins 会抓它);worker 更早。
|
|
2045
|
+
seed_copilot_db(
|
|
2046
|
+
&home,
|
|
2047
|
+
&[
|
|
2048
|
+
(worker_id, &cwd.to_string_lossy(), 100),
|
|
2049
|
+
(leader_id, &cwd.to_string_lossy(), 999),
|
|
2050
|
+
],
|
|
2051
|
+
);
|
|
2052
|
+
let _h = HomeGuard::set(&home);
|
|
2053
|
+
let ctx = CaptureSessionContext {
|
|
2054
|
+
agent_id: "worker".to_string(),
|
|
2055
|
+
spawn_cwd: cwd.clone(),
|
|
2056
|
+
pane_id: None,
|
|
2057
|
+
pane_pid: None,
|
|
2058
|
+
spawned_at: None,
|
|
2059
|
+
expected_session_id: Some(SessionId::new(worker_id)),
|
|
2060
|
+
provider_projects_root: None,
|
|
2061
|
+
};
|
|
2062
|
+
let out = scan_copilot_session_store(&ctx);
|
|
2063
|
+
assert_eq!(out.len(), 1, "expected-id point query → single authoritative candidate");
|
|
2064
|
+
assert_eq!(
|
|
2065
|
+
out[0].captured.session_id.as_ref().unwrap().as_str(),
|
|
2066
|
+
worker_id,
|
|
2067
|
+
"must return worker's own (expected) session, NOT leader's latest"
|
|
2068
|
+
);
|
|
2069
|
+
drop(_h);
|
|
2070
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
#[test]
|
|
2074
|
+
#[serial_test::serial(env)]
|
|
2075
|
+
fn copilot_expected_id_absent_in_db_returns_empty_not_leader() {
|
|
2076
|
+
// expected 设了但 db 无该 id(会话还没落)→ 返空(收敛重试),绝不回落抓 leader latest。
|
|
2077
|
+
let base = tmp_root("e11-copilot-absent");
|
|
2078
|
+
let home = base.join("home");
|
|
2079
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
2080
|
+
let cwd = base.join("ws");
|
|
2081
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
2082
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
2083
|
+
seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
|
|
2084
|
+
let _h = HomeGuard::set(&home);
|
|
2085
|
+
let ctx = CaptureSessionContext {
|
|
2086
|
+
agent_id: "worker".to_string(),
|
|
2087
|
+
spawn_cwd: cwd.clone(),
|
|
2088
|
+
pane_id: None,
|
|
2089
|
+
pane_pid: None,
|
|
2090
|
+
spawned_at: None,
|
|
2091
|
+
expected_session_id: Some(SessionId::new("1142c4c2-0000-4000-8000-000000000001")),
|
|
2092
|
+
provider_projects_root: None,
|
|
2093
|
+
};
|
|
2094
|
+
let out = scan_copilot_session_store(&ctx);
|
|
2095
|
+
assert!(
|
|
2096
|
+
out.is_empty(),
|
|
2097
|
+
"expected id absent in db → empty (no promote, no leader latest); got {out:?}"
|
|
2098
|
+
);
|
|
2099
|
+
drop(_h);
|
|
2100
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
#[test]
|
|
2104
|
+
#[serial_test::serial(env)]
|
|
2105
|
+
fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
|
|
2106
|
+
// E11 层1 兜底洞(architect):无 expected + 同 cwd 仅 leader row → 必返空,绝不返 leader。
|
|
2107
|
+
// (真实 copilot worker 恒有 expected,此分支不可达;保守返空堵住 allocator 不排除 leader 的洞。)
|
|
2108
|
+
let base = tmp_root("e11-copilot-noexp");
|
|
2109
|
+
let home = base.join("home");
|
|
2110
|
+
std::fs::create_dir_all(&home).unwrap();
|
|
2111
|
+
let cwd = base.join("ws");
|
|
2112
|
+
std::fs::create_dir_all(&cwd).unwrap();
|
|
2113
|
+
let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
|
|
2114
|
+
seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
|
|
2115
|
+
let _h = HomeGuard::set(&home);
|
|
2116
|
+
let ctx = CaptureSessionContext {
|
|
2117
|
+
agent_id: "worker".to_string(),
|
|
2118
|
+
spawn_cwd: cwd.clone(),
|
|
2119
|
+
pane_id: None,
|
|
2120
|
+
pane_pid: None,
|
|
2121
|
+
spawned_at: None,
|
|
2122
|
+
expected_session_id: None, // 无 expected → 兜底路径
|
|
2123
|
+
provider_projects_root: None,
|
|
2124
|
+
};
|
|
2125
|
+
let out = scan_copilot_session_store(&ctx);
|
|
2126
|
+
assert!(
|
|
2127
|
+
out.is_empty(),
|
|
2128
|
+
"no expected + only leader row in same cwd → must return empty, NOT leader; got {out:?}"
|
|
2129
|
+
);
|
|
2130
|
+
drop(_h);
|
|
2131
|
+
let _ = std::fs::remove_dir_all(&base);
|
|
2132
|
+
}
|
|
1971
2133
|
}
|
|
@@ -422,6 +422,7 @@ fn py_repr_str(s: &str) -> String {
|
|
|
422
422
|
mod tests {
|
|
423
423
|
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
|
|
424
424
|
use super::*;
|
|
425
|
+
use serial_test::serial;
|
|
425
426
|
use std::collections::HashMap;
|
|
426
427
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
427
428
|
|
|
@@ -446,6 +447,32 @@ mod tests {
|
|
|
446
447
|
ws
|
|
447
448
|
}
|
|
448
449
|
|
|
450
|
+
struct EnvUnsetGuard {
|
|
451
|
+
previous: Vec<(&'static str, Option<String>)>,
|
|
452
|
+
}
|
|
453
|
+
impl EnvUnsetGuard {
|
|
454
|
+
fn unset(keys: &[&'static str]) -> Self {
|
|
455
|
+
let previous = keys
|
|
456
|
+
.iter()
|
|
457
|
+
.map(|key| (*key, std::env::var(key).ok()))
|
|
458
|
+
.collect::<Vec<_>>();
|
|
459
|
+
for key in keys {
|
|
460
|
+
std::env::remove_var(key);
|
|
461
|
+
}
|
|
462
|
+
Self { previous }
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
impl Drop for EnvUnsetGuard {
|
|
466
|
+
fn drop(&mut self) {
|
|
467
|
+
for (key, value) in self.previous.drain(..).rev() {
|
|
468
|
+
match value {
|
|
469
|
+
Some(value) => std::env::set_var(key, value),
|
|
470
|
+
None => std::env::remove_var(key),
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
449
476
|
#[test]
|
|
450
477
|
fn os_user_and_fingerprint() {
|
|
451
478
|
assert_eq!(identity_os_user(&env(&[("USER", "alice")])), "alice");
|
|
@@ -560,7 +587,9 @@ mod tests {
|
|
|
560
587
|
}
|
|
561
588
|
|
|
562
589
|
#[test]
|
|
590
|
+
#[serial(env)]
|
|
563
591
|
fn apply_first_time_binding_success_writes_state() {
|
|
592
|
+
let _env = EnvUnsetGuard::unset(&["TMUX", "TMUX_PANE"]);
|
|
564
593
|
let ws = temp_ws();
|
|
565
594
|
let now = "2026-06-02T09:17:59.994383+00:00";
|
|
566
595
|
let mut state = json!({});
|
|
@@ -503,6 +503,50 @@
|
|
|
503
503
|
);
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
#[test]
|
|
507
|
+
fn has_pane_is_direct_existence_probe_not_liveness_guess() {
|
|
508
|
+
let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
|
|
509
|
+
assert_eq!(be.has_pane(&PaneId::new("%7")).expect("has_pane"), Some(true));
|
|
510
|
+
let argv0 = rec.lock().unwrap()[0].clone();
|
|
511
|
+
assert!(
|
|
512
|
+
argv0.contains(&"display-message".to_string())
|
|
513
|
+
&& argv0.iter().any(|x| x.contains("#{pane_id}"))
|
|
514
|
+
&& argv0.contains(&"%7".to_string()),
|
|
515
|
+
"has_pane must use the cheap display-message #{{pane_id}} probe; got {argv0:?}"
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
let (be, _r) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
519
|
+
assert_eq!(
|
|
520
|
+
be.has_pane(&PaneId::new("%9999")).expect("has_pane"),
|
|
521
|
+
Some(false),
|
|
522
|
+
"real tmux can report a missing pane as exit 0 with empty stdout"
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane: %9999")), vec![]);
|
|
526
|
+
assert_eq!(be.has_pane(&PaneId::new("%9999")).expect("has_pane"), Some(false));
|
|
527
|
+
|
|
528
|
+
let (be, _r) = backend_with(MockResp::Out(ok("%8")), vec![]);
|
|
529
|
+
assert_eq!(
|
|
530
|
+
be.has_pane(&PaneId::new("%7")).expect("has_pane"),
|
|
531
|
+
None,
|
|
532
|
+
"a successful but mismatched pane id is not proof that the requested pane exists"
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
let (be, _r) = backend_with(MockResp::Out(ok("not-a-pane")), vec![]);
|
|
536
|
+
assert_eq!(
|
|
537
|
+
be.has_pane(&PaneId::new("%7")).expect("has_pane"),
|
|
538
|
+
None,
|
|
539
|
+
"a successful but invalid pane id stays Unknown"
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
let (be, _r) = backend_with(MockResp::Out(fail(1, "error connecting to server: No such file or directory")), vec![]);
|
|
543
|
+
assert_eq!(
|
|
544
|
+
be.has_pane(&PaneId::new("%7")).expect("has_pane"),
|
|
545
|
+
None,
|
|
546
|
+
"server/probe errors remain Unknown, not absent"
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
506
550
|
// ── CP-1: per-team socket — for_workspace injects `-L ta-<hash>` at the run chokepoint; new() does NOT ─
|
|
507
551
|
#[test]
|
|
508
552
|
fn for_workspace_backend_injects_per_team_socket_but_default_backend_does_not() {
|