@team-agent/installer 0.3.8 → 0.3.10
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/emit.rs +20 -4
- package/crates/team-agent/src/cli/leader.rs +12 -8
- package/crates/team-agent/src/cli/tests/base.rs +14 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
- package/crates/team-agent/src/coordinator/tests/spine.rs +6 -0
- package/crates/team-agent/src/coordinator/tick.rs +83 -1
- package/crates/team-agent/src/leader/lease.rs +19 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +12 -0
- package/crates/team-agent/src/leader/rediscover.rs +2 -0
- 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 +35 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -3
- package/crates/team-agent/src/lifecycle/restart/common.rs +75 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +211 -6
- package/crates/team-agent/src/lifecycle/restart/selection.rs +51 -14
- package/crates/team-agent/src/lifecycle/restart.rs +8 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +89 -15
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +144 -3
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +3 -1
- package/crates/team-agent/src/messaging/delivery.rs +83 -2
- package/crates/team-agent/src/messaging/results.rs +27 -22
- package/crates/team-agent/src/messaging/tests/runtime.rs +108 -0
- package/crates/team-agent/src/provider/approvals/parsing.rs +43 -14
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +12 -9
- package/crates/team-agent/src/transport/test_support.rs +12 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -23,7 +23,7 @@ pub fn emit(output: &CmdOutput, as_json: bool) -> Option<String> {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude passthrough 早返回 →
|
|
26
|
+
/// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude/copilot passthrough 早返回 →
|
|
27
27
|
/// 解析 argv 到 subcommand → 调对应 handler → 异常落盘 + 信封 + `ExitCode::Error` →
|
|
28
28
|
/// `consume_leader_inbox_summary` → `emit` → `result.ok is False ? Error : Ok`。
|
|
29
29
|
/// **行为入口**:契约可端到端跑 argv→(stdout, exit code)。
|
|
@@ -31,7 +31,7 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
|
|
|
31
31
|
let Some(command) = argv.first().map(String::as_str) else {
|
|
32
32
|
return emit_missing_subcommand_usage();
|
|
33
33
|
};
|
|
34
|
-
if command == "codex" || command == "claude" {
|
|
34
|
+
if command == "codex" || command == "claude" || command == "copilot" {
|
|
35
35
|
return cmd_leader_passthrough(command, &argv[1..], cwd)
|
|
36
36
|
.map(emit_result)
|
|
37
37
|
.unwrap_or(ExitCode::Error);
|
|
@@ -193,7 +193,7 @@ fn is_known_subcommand(command: &str) -> bool {
|
|
|
193
193
|
fn command_help(command: Option<&str>) -> String {
|
|
194
194
|
match command {
|
|
195
195
|
None => {
|
|
196
|
-
let mut commands = vec!["codex", "claude"];
|
|
196
|
+
let mut commands = vec!["codex", "claude", "copilot"];
|
|
197
197
|
commands.extend_from_slice(DISPATCH_COMMANDS);
|
|
198
198
|
commands.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
|
|
199
199
|
format!(
|
|
@@ -261,7 +261,7 @@ fn emit_unknown_subcommand_usage(command: &str) -> ExitCode {
|
|
|
261
261
|
|
|
262
262
|
/// 在已知子命令里找与 `input` 最接近的一个(Levenshtein ≤ 阈值)。无足够接近者 → None。
|
|
263
263
|
fn nearest_subcommand(input: &str) -> Option<&'static str> {
|
|
264
|
-
let mut candidates: Vec<&'static str> = vec!["codex", "claude"];
|
|
264
|
+
let mut candidates: Vec<&'static str> = vec!["codex", "claude", "copilot"];
|
|
265
265
|
candidates.extend_from_slice(DISPATCH_COMMANDS);
|
|
266
266
|
candidates.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
|
|
267
267
|
// 阈值随长度放宽,但短词收紧,避免 'x' 误配任何东西。
|
|
@@ -1418,6 +1418,22 @@ mod tests {
|
|
|
1418
1418
|
"top-level --help is missing spec-only help command `{command}`"
|
|
1419
1419
|
);
|
|
1420
1420
|
}
|
|
1421
|
+
assert!(
|
|
1422
|
+
top_help.contains("copilot"),
|
|
1423
|
+
"top-level leader passthrough help must list copilot"
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
#[test]
|
|
1428
|
+
fn copilot_is_listed_as_leader_passthrough_candidate() {
|
|
1429
|
+
assert!(command_help(None).contains("copilot"));
|
|
1430
|
+
assert_eq!(nearest_subcommand("copliot"), Some("copilot"));
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
#[test]
|
|
1434
|
+
fn copilot_help_dispatches_as_leader_passthrough() {
|
|
1435
|
+
let cwd = tmp_workspace();
|
|
1436
|
+
assert_eq!(run(&cli_argv(&["copilot", "--help"]), &cwd), ExitCode::Ok);
|
|
1421
1437
|
}
|
|
1422
1438
|
|
|
1423
1439
|
#[test]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//! cli · leader — `codex`/`claude` passthrough(`cmd_leader_passthrough` + `_provider_args` /
|
|
1
|
+
//! cli · leader — `codex`/`claude`/`copilot` passthrough(`cmd_leader_passthrough` + `_provider_args` /
|
|
2
2
|
//! `_leader_launcher_args`)+ leader fallback inbox 摘要(`consume_leader_inbox_summary` 及
|
|
3
3
|
//! `_leader_inbox_entries` / `_leader_inbox_summary` / `_leader_inbox_entry_title`)。
|
|
4
4
|
|
|
@@ -68,9 +68,9 @@ fn without_leader_json(values: &[String]) -> Vec<String> {
|
|
|
68
68
|
out
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/// `codex`/`claude` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
|
|
71
|
+
/// `codex`/`claude`/`copilot` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
|
|
72
72
|
/// **不**进 subparser。`-h`/`--help` 打 usage 直接返回 [`CmdResult::none`]。否则解析 attach
|
|
73
|
-
/// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude}。
|
|
73
|
+
/// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude, copilot}。
|
|
74
74
|
pub fn cmd_leader_passthrough(
|
|
75
75
|
command: &str,
|
|
76
76
|
provider_args: &[String],
|
|
@@ -82,15 +82,19 @@ pub fn cmd_leader_passthrough(
|
|
|
82
82
|
let as_json = leader_launcher_json(provider_args);
|
|
83
83
|
let launcher_args = without_leader_json(provider_args);
|
|
84
84
|
let attach = leader_launcher_args(&launcher_args)?;
|
|
85
|
-
let provider =
|
|
86
|
-
crate::model::enums::Provider::Codex
|
|
87
|
-
} else {
|
|
88
|
-
crate::model::enums::Provider::ClaudeCode
|
|
89
|
-
};
|
|
85
|
+
let provider = leader_passthrough_provider(command);
|
|
90
86
|
let value = lifecycle_port::start_leader(provider, &attach.provider_args, cwd, &attach)?;
|
|
91
87
|
Ok(CmdResult::from_json(value, as_json))
|
|
92
88
|
}
|
|
93
89
|
|
|
90
|
+
pub(crate) fn leader_passthrough_provider(command: &str) -> crate::model::enums::Provider {
|
|
91
|
+
match command {
|
|
92
|
+
"codex" => crate::model::enums::Provider::Codex,
|
|
93
|
+
"copilot" => crate::model::enums::Provider::Copilot,
|
|
94
|
+
_ => crate::model::enums::Provider::ClaudeCode,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
94
98
|
// =============================================================================
|
|
95
99
|
// leader fallback inbox 摘要(helpers.py `consume_leader_inbox_summary`)
|
|
96
100
|
// =============================================================================
|
|
@@ -617,5 +617,19 @@ Truncated: more fallback entries available; run team-agent inbox leader";
|
|
|
617
617
|
assert_eq!(r.exit, ExitCode::Ok);
|
|
618
618
|
let r2 = cmd_leader_passthrough("claude", &["--help".into()], Path::new(".")).unwrap();
|
|
619
619
|
assert_eq!(r2.output, CmdOutput::None);
|
|
620
|
+
let r3 = cmd_leader_passthrough("copilot", &["--help".into()], Path::new(".")).unwrap();
|
|
621
|
+
assert_eq!(r3.output, CmdOutput::None);
|
|
620
622
|
}
|
|
621
623
|
|
|
624
|
+
#[test]
|
|
625
|
+
fn cmd_leader_passthrough_maps_copilot_provider() {
|
|
626
|
+
assert_eq!(leader_passthrough_provider("codex"), crate::model::enums::Provider::Codex);
|
|
627
|
+
assert_eq!(
|
|
628
|
+
leader_passthrough_provider("claude"),
|
|
629
|
+
crate::model::enums::Provider::ClaudeCode
|
|
630
|
+
);
|
|
631
|
+
assert_eq!(
|
|
632
|
+
leader_passthrough_provider("copilot"),
|
|
633
|
+
crate::model::enums::Provider::Copilot
|
|
634
|
+
);
|
|
635
|
+
}
|
|
@@ -264,9 +264,13 @@ fn current_uid() -> Option<String> {
|
|
|
264
264
|
json: true,
|
|
265
265
|
};
|
|
266
266
|
let text = outcome_text(cmd_restart(&args));
|
|
267
|
+
// RED-2-STILL: entry gate now resolves via canonical_run_workspace; for a no-spec workspace
|
|
268
|
+
// the "no spec" surfacing may come from either the entry gate ("missing spec for restart")
|
|
269
|
+
// or, when a stray ancestor .team lets it past, the resolve gate ("active team spec not
|
|
270
|
+
// found"). Both are the REAL crate::lifecycle error (not a placeholder ok:true).
|
|
267
271
|
assert!(
|
|
268
|
-
text.contains("missing spec for restart"),
|
|
269
|
-
"cmd_restart must surface the REAL crate::lifecycle::restart
|
|
272
|
+
text.contains("missing spec for restart") || text.contains("active team spec not found"),
|
|
273
|
+
"cmd_restart must surface the REAL crate::lifecycle::restart missing-spec error \
|
|
270
274
|
(not the placeholder's canned ok:true); got {text}"
|
|
271
275
|
);
|
|
272
276
|
}
|
|
@@ -258,4 +258,10 @@ fn spine_tick_session_missing_emits_event() {
|
|
|
258
258
|
.any(|e| e.get("event").and_then(|v| v.as_str()) == Some("coordinator.session_missing")),
|
|
259
259
|
"the tmux-missing gate must emit a coordinator.session_missing event before the stop report; got {events:?}"
|
|
260
260
|
);
|
|
261
|
+
assert!(
|
|
262
|
+
events
|
|
263
|
+
.iter()
|
|
264
|
+
.any(|e| e.get("event").and_then(|v| v.as_str()) == Some("coordinator.session_missing_alert")),
|
|
265
|
+
"the tmux-missing gate must emit an explicit leader-visible alert before stopping; got {events:?}"
|
|
266
|
+
);
|
|
261
267
|
}
|
|
@@ -198,6 +198,7 @@ impl Coordinator {
|
|
|
198
198
|
"coordinator.session_missing",
|
|
199
199
|
serde_json::json!({"session": session_name}),
|
|
200
200
|
)?;
|
|
201
|
+
notify_session_missing(self.workspace.as_path(), &state, &event_log, session_name)?;
|
|
201
202
|
return Ok(empty_tick_report(
|
|
202
203
|
false,
|
|
203
204
|
true,
|
|
@@ -971,7 +972,8 @@ impl Coordinator {
|
|
|
971
972
|
"runtime_approval.auto_approved",
|
|
972
973
|
serde_json::json!({
|
|
973
974
|
"agent_id": agent_id,
|
|
974
|
-
"
|
|
975
|
+
"server": prompt.server.as_deref(),
|
|
976
|
+
"tool": prompt.tool.as_deref(),
|
|
975
977
|
"choice": choice,
|
|
976
978
|
"cleared": cleared,
|
|
977
979
|
"policy_source": approval_policy.source,
|
|
@@ -980,6 +982,23 @@ impl Coordinator {
|
|
|
980
982
|
"worker_capability_above_leader": approval_policy.worker_capability_above_leader,
|
|
981
983
|
}),
|
|
982
984
|
)?;
|
|
985
|
+
event_log.write(
|
|
986
|
+
"mcp.tool.auto_approved",
|
|
987
|
+
serde_json::json!({
|
|
988
|
+
"agent_id": agent_id,
|
|
989
|
+
"server": prompt.server.as_deref(),
|
|
990
|
+
"tool": prompt.tool.as_deref(),
|
|
991
|
+
"choice": choice,
|
|
992
|
+
"cleared": cleared,
|
|
993
|
+
"inherit_reason": approval_policy.inherit_reason(),
|
|
994
|
+
"bypass_source": approval_policy.source,
|
|
995
|
+
"provider": approval_policy.provider,
|
|
996
|
+
"flag": approval_policy.flag,
|
|
997
|
+
"inherited": approval_policy.inherited,
|
|
998
|
+
"explicit_yes_confirmed": approval_policy.explicit_yes_confirmed,
|
|
999
|
+
"worker_capability_above_leader": approval_policy.worker_capability_above_leader,
|
|
1000
|
+
}),
|
|
1001
|
+
)?;
|
|
983
1002
|
}
|
|
984
1003
|
RuntimeApprovalDecision::AwaitingHumanConfirm => {
|
|
985
1004
|
let Some(reason) = awaiting_human_confirm_reason(&prompt, auto_answer_allowed) else {
|
|
@@ -2110,6 +2129,8 @@ struct RuntimeApprovalPolicy {
|
|
|
2110
2129
|
source: String,
|
|
2111
2130
|
inherited: bool,
|
|
2112
2131
|
explicit_yes_confirmed: bool,
|
|
2132
|
+
provider: Option<String>,
|
|
2133
|
+
flag: Option<String>,
|
|
2113
2134
|
worker_capability_above_leader: bool,
|
|
2114
2135
|
}
|
|
2115
2136
|
|
|
@@ -2127,6 +2148,14 @@ impl RuntimeApprovalPolicy {
|
|
|
2127
2148
|
&& (!self.worker_capability_above_leader
|
|
2128
2149
|
|| (self.source == "runtime_config" && self.explicit_yes_confirmed))
|
|
2129
2150
|
}
|
|
2151
|
+
|
|
2152
|
+
fn inherit_reason(&self) -> &'static str {
|
|
2153
|
+
match self.source.as_str() {
|
|
2154
|
+
"leader_process" if self.inherited => "leader_bypass",
|
|
2155
|
+
"runtime_config" if self.explicit_yes_confirmed => "runtime_config_explicit_yes",
|
|
2156
|
+
_ => "none",
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2130
2159
|
}
|
|
2131
2160
|
|
|
2132
2161
|
fn runtime_approval_policy_from_agent(agent: &Value) -> RuntimeApprovalPolicy {
|
|
@@ -2151,6 +2180,14 @@ fn runtime_approval_policy_from_agent(agent: &Value) -> RuntimeApprovalPolicy {
|
|
|
2151
2180
|
.and_then(|p| p.get("explicit_yes_confirmed"))
|
|
2152
2181
|
.and_then(Value::as_bool)
|
|
2153
2182
|
.unwrap_or(false),
|
|
2183
|
+
provider: policy
|
|
2184
|
+
.and_then(|p| p.get("provider"))
|
|
2185
|
+
.and_then(Value::as_str)
|
|
2186
|
+
.map(str::to_string),
|
|
2187
|
+
flag: policy
|
|
2188
|
+
.and_then(|p| p.get("flag"))
|
|
2189
|
+
.and_then(Value::as_str)
|
|
2190
|
+
.map(str::to_string),
|
|
2154
2191
|
worker_capability_above_leader: policy
|
|
2155
2192
|
.and_then(|p| p.get("worker_capability_above_leader"))
|
|
2156
2193
|
.and_then(Value::as_bool)
|
|
@@ -2423,3 +2460,48 @@ fn remove_file_if_exists(path: &Path) -> Result<(), std::io::Error> {
|
|
|
2423
2460
|
Err(e) => Err(e),
|
|
2424
2461
|
}
|
|
2425
2462
|
}
|
|
2463
|
+
|
|
2464
|
+
fn notify_session_missing(
|
|
2465
|
+
workspace: &Path,
|
|
2466
|
+
state: &Value,
|
|
2467
|
+
event_log: &EventLog,
|
|
2468
|
+
session_name: &str,
|
|
2469
|
+
) -> Result<(), TickError> {
|
|
2470
|
+
let content = format!(
|
|
2471
|
+
"coordinator.session_missing\nerror: tmux session {session_name} is missing; coordinator is stopping\naction: restart the team or recover the missing tmux session\nlog: .team/logs/events.jsonl"
|
|
2472
|
+
);
|
|
2473
|
+
let dedupe_key = format!("coordinator.session_missing:{session_name}");
|
|
2474
|
+
match crate::messaging::send_to_leader_receiver(
|
|
2475
|
+
workspace,
|
|
2476
|
+
state,
|
|
2477
|
+
"leader",
|
|
2478
|
+
&content,
|
|
2479
|
+
None,
|
|
2480
|
+
"coordinator",
|
|
2481
|
+
false,
|
|
2482
|
+
Some(&dedupe_key),
|
|
2483
|
+
event_log,
|
|
2484
|
+
) {
|
|
2485
|
+
Ok(outcome) => {
|
|
2486
|
+
event_log.write(
|
|
2487
|
+
"coordinator.session_missing_alert",
|
|
2488
|
+
serde_json::json!({
|
|
2489
|
+
"session": session_name,
|
|
2490
|
+
"leader_notification_status": crate::messaging::helpers::status_wire(outcome.status),
|
|
2491
|
+
"message_id": outcome.message_id,
|
|
2492
|
+
}),
|
|
2493
|
+
)?;
|
|
2494
|
+
}
|
|
2495
|
+
Err(error) => {
|
|
2496
|
+
event_log.write(
|
|
2497
|
+
"coordinator.session_missing_alert_failed",
|
|
2498
|
+
serde_json::json!({
|
|
2499
|
+
"session": session_name,
|
|
2500
|
+
"error": error.to_string(),
|
|
2501
|
+
"action": "inspect .team/logs/events.jsonl and restart the team",
|
|
2502
|
+
}),
|
|
2503
|
+
)?;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
Ok(())
|
|
2507
|
+
}
|
|
@@ -654,6 +654,8 @@ fn leader_command_provider(command: &str) -> Option<Provider> {
|
|
|
654
654
|
Some(Provider::ClaudeCode)
|
|
655
655
|
} else if lower.contains("codex") {
|
|
656
656
|
Some(Provider::Codex)
|
|
657
|
+
} else if lower.contains("copilot") {
|
|
658
|
+
Some(Provider::Copilot)
|
|
657
659
|
} else if lower.contains("fake") {
|
|
658
660
|
Some(Provider::Fake)
|
|
659
661
|
} else {
|
|
@@ -1082,3 +1084,20 @@ pub fn detect_dual_state_divergence(
|
|
|
1082
1084
|
"team_owner_epoch": team_epoch,
|
|
1083
1085
|
})))
|
|
1084
1086
|
}
|
|
1087
|
+
|
|
1088
|
+
#[cfg(test)]
|
|
1089
|
+
mod tests {
|
|
1090
|
+
use super::*;
|
|
1091
|
+
|
|
1092
|
+
#[test]
|
|
1093
|
+
fn leader_command_provider_recognizes_copilot() {
|
|
1094
|
+
assert_eq!(
|
|
1095
|
+
leader_command_provider("copilot --allow-all-tools"),
|
|
1096
|
+
Some(Provider::Copilot)
|
|
1097
|
+
);
|
|
1098
|
+
assert_eq!(
|
|
1099
|
+
leader_command_provider("/usr/local/bin/copilot"),
|
|
1100
|
+
Some(Provider::Copilot)
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
@@ -80,6 +80,18 @@ fn event_named(events: &[Value], name: &str) -> Value {
|
|
|
80
80
|
.unwrap_or_else(|| panic!("missing event {name}; got {events:?}"))
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
#[test]
|
|
84
|
+
fn leader_command_provider_recognizes_copilot() {
|
|
85
|
+
assert_eq!(
|
|
86
|
+
leader_command_provider("copilot --allow-all-tools"),
|
|
87
|
+
Some(Provider::Copilot)
|
|
88
|
+
);
|
|
89
|
+
assert_eq!(
|
|
90
|
+
leader_command_provider("/usr/local/bin/copilot"),
|
|
91
|
+
Some(Provider::Copilot)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
fn last_event_named(events: &[Value], name: &str) -> Value {
|
|
84
96
|
events
|
|
85
97
|
.iter()
|
|
@@ -590,6 +590,8 @@ fn leader_command_provider(command: &str) -> Option<Provider> {
|
|
|
590
590
|
Some(Provider::ClaudeCode)
|
|
591
591
|
} else if lower.contains("codex") {
|
|
592
592
|
Some(Provider::Codex)
|
|
593
|
+
} else if lower.contains("copilot") {
|
|
594
|
+
Some(Provider::Copilot)
|
|
593
595
|
} else if lower.contains("fake") {
|
|
594
596
|
Some(Provider::Fake)
|
|
595
597
|
} else {
|
|
@@ -14,8 +14,8 @@ use super::helpers::{
|
|
|
14
14
|
};
|
|
15
15
|
use super::owner_bind::leader_identity_context;
|
|
16
16
|
use super::{
|
|
17
|
-
LeaderError, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus,
|
|
18
|
-
LeaderStartPlan,
|
|
17
|
+
LeaderError, LeaderIdentity, LeaderLaunchOutcome, LeaderLaunchSocket, LeaderLaunchStatus,
|
|
18
|
+
LeaderStartMode, LeaderStartPlan,
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
// ── leader::start — leader_start_plan / start_leader / session 名 ──
|
|
@@ -72,27 +72,7 @@ pub fn leader_start_plan(
|
|
|
72
72
|
} else {
|
|
73
73
|
LeaderStartMode::NewTmuxSession
|
|
74
74
|
};
|
|
75
|
-
let
|
|
76
|
-
leader_env.insert(
|
|
77
|
-
"TEAM_AGENT_LEADER_PROVIDER".to_string(),
|
|
78
|
-
provider_wire(provider).to_string(),
|
|
79
|
-
);
|
|
80
|
-
leader_env.insert(
|
|
81
|
-
"TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
|
|
82
|
-
identity.leader_session_uuid.as_str().to_string(),
|
|
83
|
-
);
|
|
84
|
-
leader_env.insert(
|
|
85
|
-
"TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
|
|
86
|
-
identity.machine_fingerprint.clone(),
|
|
87
|
-
);
|
|
88
|
-
leader_env.insert(
|
|
89
|
-
"TEAM_AGENT_WORKSPACE".to_string(),
|
|
90
|
-
identity.workspace_abspath.to_string_lossy().into_owned(),
|
|
91
|
-
);
|
|
92
|
-
leader_env.insert(
|
|
93
|
-
"TEAM_AGENT_TEAM_ID".to_string(),
|
|
94
|
-
identity.team_id.as_str().to_string(),
|
|
95
|
-
);
|
|
75
|
+
let leader_env = leader_env_for_identity(provider, &identity);
|
|
96
76
|
let argv = start_argv(
|
|
97
77
|
mode,
|
|
98
78
|
provider,
|
|
@@ -119,6 +99,37 @@ pub fn leader_start_plan(
|
|
|
119
99
|
})
|
|
120
100
|
}
|
|
121
101
|
|
|
102
|
+
pub(crate) fn leader_env_for_identity(
|
|
103
|
+
provider: Provider,
|
|
104
|
+
identity: &LeaderIdentity,
|
|
105
|
+
) -> BTreeMap<String, String> {
|
|
106
|
+
let mut leader_env = BTreeMap::new();
|
|
107
|
+
leader_env.insert(
|
|
108
|
+
"TEAM_AGENT_LEADER_PROVIDER".to_string(),
|
|
109
|
+
provider_wire(provider).to_string(),
|
|
110
|
+
);
|
|
111
|
+
leader_env.insert(
|
|
112
|
+
"TEAM_AGENT_LEADER_SESSION_UUID".to_string(),
|
|
113
|
+
identity.leader_session_uuid.as_str().to_string(),
|
|
114
|
+
);
|
|
115
|
+
leader_env.insert(
|
|
116
|
+
"TEAM_AGENT_MACHINE_FINGERPRINT".to_string(),
|
|
117
|
+
identity.machine_fingerprint.clone(),
|
|
118
|
+
);
|
|
119
|
+
leader_env.insert(
|
|
120
|
+
"TEAM_AGENT_WORKSPACE".to_string(),
|
|
121
|
+
identity.workspace_abspath.to_string_lossy().into_owned(),
|
|
122
|
+
);
|
|
123
|
+
leader_env.insert(
|
|
124
|
+
"TEAM_AGENT_TEAM_ID".to_string(),
|
|
125
|
+
identity.team_id.as_str().to_string(),
|
|
126
|
+
);
|
|
127
|
+
if provider == Provider::Copilot {
|
|
128
|
+
leader_env.insert("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string());
|
|
129
|
+
}
|
|
130
|
+
leader_env
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
/// `start_leader`(card §46;`__init__.py:60`)。计算并执行 leader 启动计划(spawn + 信号处理)。
|
|
123
134
|
/// 进程退出后 `Err`/退出码经 caller 处理(此处返 `Result` 替代 Python 的 `SystemExit`)。
|
|
124
135
|
pub fn start_leader(
|
|
@@ -90,6 +90,28 @@ use super::*;
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
#[test]
|
|
94
|
+
#[serial_test::serial(env)]
|
|
95
|
+
fn copilot_leader_env_disables_terminal_title_only_for_copilot() {
|
|
96
|
+
let ws = std::env::temp_dir().join(format!("ta_rs_lsp_copilot_{}", std::process::id()));
|
|
97
|
+
std::fs::create_dir_all(&ws).unwrap();
|
|
98
|
+
let identity = leader_identity_context(&ws, None, Some(&serde_json::json!({}))).unwrap();
|
|
99
|
+
|
|
100
|
+
let copilot = leader_env_for_identity(Provider::Copilot, &identity);
|
|
101
|
+
assert_eq!(
|
|
102
|
+
copilot.get("COPILOT_DISABLE_TERMINAL_TITLE").map(String::as_str),
|
|
103
|
+
Some("1")
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
for provider in [Provider::Codex, Provider::ClaudeCode] {
|
|
107
|
+
let leader_env = leader_env_for_identity(provider, &identity);
|
|
108
|
+
assert!(
|
|
109
|
+
!leader_env.contains_key("COPILOT_DISABLE_TERMINAL_TITLE"),
|
|
110
|
+
"{provider:?} leader env must not include copilot title override"
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
// ═══════════════ P2 FIX-LOOP RED (复绿即对抗 cross-model findings) ═══════════════
|
|
94
116
|
// Lock CORRECT Python v0.2.11 leader-identity behavior the contracts missed.
|
|
95
117
|
// Golden re-probed via /tmp/probe_p2_leader.py vs team-agent-public @ 439bef8
|
|
@@ -134,6 +134,19 @@ use super::*;
|
|
|
134
134
|
assert!(got.as_str().starts_with("team-agent-leader-claude_code-proj-"), "got {}", got.as_str());
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
#[test]
|
|
138
|
+
fn leader_session_name_uses_copilot_provider_string() {
|
|
139
|
+
let base = std::env::temp_dir().join(format!("ta_rs_lsn_copilot_{}", std::process::id()));
|
|
140
|
+
let dir = base.join("proj");
|
|
141
|
+
std::fs::create_dir_all(&dir).unwrap();
|
|
142
|
+
let got = leader_session_name(Provider::Copilot, &dir);
|
|
143
|
+
assert!(
|
|
144
|
+
got.as_str().starts_with("team-agent-leader-copilot-proj-"),
|
|
145
|
+
"got {}",
|
|
146
|
+
got.as_str()
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
137
150
|
// =====================================================================
|
|
138
151
|
// 6. Family A 正源 owner 绑定 — bind_owner_from_caller_pane(unimplemented → RED)
|
|
139
152
|
// =====================================================================
|
|
@@ -288,6 +288,7 @@ fn spawn_agents(
|
|
|
288
288
|
let mut env =
|
|
289
289
|
inherited_env_with_team_overrides(workspace, agent_id_raw, Some(&mcp_team_id));
|
|
290
290
|
apply_profile_launch_env(&mut env, &profile_launch);
|
|
291
|
+
apply_mcp_auto_approval_env(&mut env, &safety);
|
|
291
292
|
// Python providers.py:145 + launch/core.py:253 — fresh launch runs the worker
|
|
292
293
|
// with cwd=workspace, same as the RS fork/add and restart paths.
|
|
293
294
|
let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
|
|
@@ -1390,6 +1391,39 @@ pub(crate) fn inherited_env_with_team_overrides(
|
|
|
1390
1391
|
env
|
|
1391
1392
|
}
|
|
1392
1393
|
|
|
1394
|
+
pub(crate) fn apply_mcp_auto_approval_env(
|
|
1395
|
+
env: &mut BTreeMap<String, String>,
|
|
1396
|
+
safety: &DangerousApproval,
|
|
1397
|
+
) {
|
|
1398
|
+
for key in [
|
|
1399
|
+
"TEAM_AGENT_LEADER_BYPASS",
|
|
1400
|
+
"TEAM_AGENT_LEADER_BYPASS_SOURCE",
|
|
1401
|
+
"TEAM_AGENT_LEADER_BYPASS_PROVIDER",
|
|
1402
|
+
"TEAM_AGENT_LEADER_BYPASS_FLAG",
|
|
1403
|
+
"TEAM_AGENT_MCP_AUTO_APPROVE",
|
|
1404
|
+
"TEAM_AGENT_MCP_AUTO_APPROVE_SOURCE",
|
|
1405
|
+
] {
|
|
1406
|
+
env.remove(key);
|
|
1407
|
+
}
|
|
1408
|
+
if safety.enabled
|
|
1409
|
+
&& matches!(safety.source, DangerousApprovalSource::LeaderProcess)
|
|
1410
|
+
&& safety.inherited
|
|
1411
|
+
{
|
|
1412
|
+
env.insert("TEAM_AGENT_LEADER_BYPASS".to_string(), "1".to_string());
|
|
1413
|
+
env.insert("TEAM_AGENT_LEADER_BYPASS_SOURCE".to_string(), "leader_process".to_string());
|
|
1414
|
+
if let Some(provider) = safety.provider.as_deref() {
|
|
1415
|
+
env.insert("TEAM_AGENT_LEADER_BYPASS_PROVIDER".to_string(), provider.to_string());
|
|
1416
|
+
}
|
|
1417
|
+
if let Some(flag) = safety.flag.as_deref() {
|
|
1418
|
+
env.insert("TEAM_AGENT_LEADER_BYPASS_FLAG".to_string(), flag.to_string());
|
|
1419
|
+
}
|
|
1420
|
+
env.insert("TEAM_AGENT_MCP_AUTO_APPROVE".to_string(), "team_orchestrator".to_string());
|
|
1421
|
+
env.insert("TEAM_AGENT_MCP_AUTO_APPROVE_SOURCE".to_string(), "leader_bypass".to_string());
|
|
1422
|
+
} else {
|
|
1423
|
+
env.insert("TEAM_AGENT_LEADER_BYPASS".to_string(), "0".to_string());
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1393
1427
|
/// BUG / B2 灵魂件 + C-1-2 + C-6-1 cr verdict — Copilot per-worker AGENTS.md
|
|
1394
1428
|
/// 写入 + `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` 注入。
|
|
1395
1429
|
///
|
|
@@ -2971,6 +3005,7 @@ pub fn fork_agent_with_transport(
|
|
|
2971
3005
|
let mut env =
|
|
2972
3006
|
inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
|
|
2973
3007
|
apply_profile_launch_env(&mut env, &profile_launch);
|
|
3008
|
+
apply_mcp_auto_approval_env(&mut env, &safety);
|
|
2974
3009
|
// golden operations.py:336 -> _tmux_start_command_for_agent_window (runtime.py:1017-1020): branch on
|
|
2975
3010
|
// _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
|
|
2976
3011
|
// (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
|
|
@@ -108,17 +108,31 @@ pub(crate) fn start_agent_at_paths(
|
|
|
108
108
|
let provider = agent_provider(&agent);
|
|
109
109
|
let session_id = agent_session_id(&agent);
|
|
110
110
|
let rollout_path = agent_rollout_path(&agent);
|
|
111
|
-
let
|
|
111
|
+
let resume_backing_exists = session_id
|
|
112
112
|
.as_ref()
|
|
113
|
-
.map(|
|
|
113
|
+
.map(|session| {
|
|
114
|
+
resume_backing_exists_for_agent(
|
|
115
|
+
workspace,
|
|
116
|
+
agent_id,
|
|
117
|
+
&agent,
|
|
118
|
+
provider,
|
|
119
|
+
session,
|
|
120
|
+
rollout_path.as_ref(),
|
|
121
|
+
)
|
|
122
|
+
})
|
|
114
123
|
.unwrap_or(false);
|
|
115
124
|
let start_mode = decide_start_mode(
|
|
116
125
|
provider_wire(provider),
|
|
117
126
|
session_id.as_ref(),
|
|
118
127
|
rollout_path.as_ref(),
|
|
119
|
-
|
|
128
|
+
resume_backing_exists,
|
|
120
129
|
allow_fresh,
|
|
121
130
|
);
|
|
131
|
+
if matches!(start_mode, StartMode::Noop) {
|
|
132
|
+
return Err(LifecycleError::RequirementUnmet(format!(
|
|
133
|
+
"resume_not_ready: session backing store missing for agent {agent_id}; rerun with --allow-fresh to start fresh"
|
|
134
|
+
)));
|
|
135
|
+
}
|
|
122
136
|
let spawn_session_id = if matches!(start_mode, StartMode::Resumed) {
|
|
123
137
|
session_id.as_ref()
|
|
124
138
|
} else {
|
|
@@ -122,6 +122,7 @@ pub(super) fn spawn_agent_window(
|
|
|
122
122
|
team_id.as_deref(),
|
|
123
123
|
);
|
|
124
124
|
crate::lifecycle::launch::apply_profile_launch_env(&mut env, &profile_launch);
|
|
125
|
+
crate::lifecycle::launch::apply_mcp_auto_approval_env(&mut env, safety);
|
|
125
126
|
let spawn_cwd = spawn_cwd_override
|
|
126
127
|
.or_else(|| {
|
|
127
128
|
agent
|
|
@@ -242,6 +243,80 @@ pub(super) fn agent_rollout_path(agent: &serde_json::Value) -> Option<RolloutPat
|
|
|
242
243
|
.map(RolloutPath::new)
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
pub(super) fn resume_backing_exists_for_agent(
|
|
247
|
+
workspace: &Path,
|
|
248
|
+
agent_id: &AgentId,
|
|
249
|
+
agent: &serde_json::Value,
|
|
250
|
+
provider: Provider,
|
|
251
|
+
session_id: &SessionId,
|
|
252
|
+
rollout_path: Option<&RolloutPath>,
|
|
253
|
+
) -> bool {
|
|
254
|
+
match provider {
|
|
255
|
+
Provider::Codex => rollout_path_exists(rollout_path),
|
|
256
|
+
Provider::Claude | Provider::ClaudeCode => {
|
|
257
|
+
rollout_path_exists(rollout_path)
|
|
258
|
+
|| event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
|
|
259
|
+
}
|
|
260
|
+
Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
|
|
261
|
+
Provider::GeminiCli | Provider::Fake => {
|
|
262
|
+
let _ = agent;
|
|
263
|
+
true
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
fn rollout_path_exists(rollout_path: Option<&RolloutPath>) -> bool {
|
|
269
|
+
rollout_path
|
|
270
|
+
.as_ref()
|
|
271
|
+
.is_some_and(|path| path.as_path().exists())
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
fn event_log_transcript_exists(workspace: &Path, agent_id: &str, session_id: &str) -> bool {
|
|
275
|
+
let Ok(events) = crate::event_log::EventLog::new(workspace).tail(0) else {
|
|
276
|
+
return false;
|
|
277
|
+
};
|
|
278
|
+
events.iter().rev().any(|event| {
|
|
279
|
+
event.get("event").and_then(serde_json::Value::as_str) == Some("session.captured")
|
|
280
|
+
&& ["agent_id", "worker_id"]
|
|
281
|
+
.iter()
|
|
282
|
+
.any(|key| event.get(*key).and_then(serde_json::Value::as_str) == Some(agent_id))
|
|
283
|
+
&& event.get("session_id").and_then(serde_json::Value::as_str) == Some(session_id)
|
|
284
|
+
&& event_transcript_path(event).is_some_and(|path| path.exists())
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fn event_transcript_path(event: &serde_json::Value) -> Option<PathBuf> {
|
|
289
|
+
event
|
|
290
|
+
.get("rollout_path")
|
|
291
|
+
.or_else(|| event.get("transcript_path"))
|
|
292
|
+
.and_then(serde_json::Value::as_str)
|
|
293
|
+
.filter(|path| !path.is_empty())
|
|
294
|
+
.map(PathBuf::from)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fn copilot_session_store_has_session(session_id: &str) -> bool {
|
|
298
|
+
let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
|
|
299
|
+
return false;
|
|
300
|
+
};
|
|
301
|
+
let db_path = home.join(".copilot").join("session-store.db");
|
|
302
|
+
if !db_path.exists() {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
let Ok(conn) = rusqlite::Connection::open_with_flags(
|
|
306
|
+
db_path,
|
|
307
|
+
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
|
308
|
+
) else {
|
|
309
|
+
return false;
|
|
310
|
+
};
|
|
311
|
+
conn
|
|
312
|
+
.query_row(
|
|
313
|
+
"select 1 from sessions where id = ?1 limit 1",
|
|
314
|
+
[session_id],
|
|
315
|
+
|_| Ok(()),
|
|
316
|
+
)
|
|
317
|
+
.is_ok()
|
|
318
|
+
}
|
|
319
|
+
|
|
245
320
|
pub(crate) fn refresh_missing_provider_sessions(
|
|
246
321
|
state: &mut serde_json::Value,
|
|
247
322
|
) -> Result<bool, LifecycleError> {
|