@team-agent/installer 0.3.8 → 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 CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.8"
569
+ version = "0.3.9"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.8"
12
+ version = "0.3.9"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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 = if command == "codex" {
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 'missing spec for restart' error \
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
  }
@@ -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, LeaderStartMode,
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 mut leader_env = BTreeMap::new();
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
  // =====================================================================
@@ -77,10 +77,17 @@ 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
- if crate::lifecycle::restart::input_has_no_local_team_context(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)
86
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
87
+ if crate::lifecycle::restart::input_has_no_local_team_context(&resolved_ws) {
81
88
  return Err(LifecycleError::TeamSelect(format!(
82
- "missing spec for restart: {}",
83
- workspace.join("team.spec.yaml").display()
89
+ "missing spec for restart: {} (run `team-agent quick-start <teamdir>` first)",
90
+ crate::model::paths::runtime_dir(&resolved_ws).display()
84
91
  )));
85
92
  }
86
93
  // RED-2(P0)修:存在性门下移到 resolve 之后,用 selected.spec_path(读序 B:runtime 优先、
@@ -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
- if input_has_no_local_team_context(workspace) {
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={} expected_spec_path={}",
58
+ "active team spec not found: input_workspace={} expected_runtime_dir={}",
55
59
  workspace.display(),
56
- workspace.join("team.spec.yaml").display()
60
+ crate::model::paths::runtime_dir(&resolved_ws).display()
57
61
  )));
58
62
  }
59
63
  let selected = crate::state::selector::resolve_active_team(
@@ -581,6 +581,82 @@ fn red2_restart_empty_workspace_still_reports_missing() {
581
581
  let _ = std::fs::remove_dir_all(&ws);
582
582
  }
583
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
+
584
660
  // E5 task#3 / RC-A6b — restart with role definitions MISSING explicitly refuses (lists what's
585
661
  // missing) and leaves the previous runtime spec in place (no silent path, no data destruction).
586
662
  #[test]
@@ -689,28 +689,33 @@ pub fn report_result_for_owner_team(
689
689
  std::thread::sleep(std::time::Duration::from_millis(50));
690
690
  }
691
691
  }
692
- match inject_leader_notification_direct(workspace, &delivery_state, &content, &message_id) {
693
- Ok(()) => {
694
- store.mark(&message_id, "delivered", None)?;
695
- outcome = crate::messaging::DeliveryOutcome {
696
- ok: true,
697
- status: crate::messaging::DeliveryStatus::Delivered,
698
- message_status: super::helpers::MessageStatusShadow("delivered".to_string()),
699
- message_id: Some(message_id),
700
- verification: None,
701
- stage: None,
702
- reason: None,
703
- channel: Some("leader_receiver".to_string()),
704
- };
705
- }
706
- Err(reason) => {
707
- event_log.write(
708
- "leader_receiver.direct_inject_skipped",
709
- serde_json::json!({
710
- "message_id": message_id,
711
- "reason": reason,
712
- }),
713
- )?;
692
+ // E15(F4.4 双投修):direct inject deliver **失败时的兜底**,不是无条件第二投。
693
+ // deliver loop 成功(outcome.ok) 跳过 direct inject,leader 仅收 deliver 那一条;
694
+ // 全失败 → direct inject 兜底投一条(不丢 result,守 #230/MUST-8)。两全:恰一条。
695
+ if !outcome.ok {
696
+ match inject_leader_notification_direct(workspace, &delivery_state, &content, &message_id) {
697
+ Ok(()) => {
698
+ store.mark(&message_id, "delivered", None)?;
699
+ outcome = crate::messaging::DeliveryOutcome {
700
+ ok: true,
701
+ status: crate::messaging::DeliveryStatus::Delivered,
702
+ message_status: super::helpers::MessageStatusShadow("delivered".to_string()),
703
+ message_id: Some(message_id),
704
+ verification: None,
705
+ stage: None,
706
+ reason: None,
707
+ channel: Some("leader_receiver".to_string()),
708
+ };
709
+ }
710
+ Err(reason) => {
711
+ event_log.write(
712
+ "leader_receiver.direct_inject_skipped",
713
+ serde_json::json!({
714
+ "message_id": message_id,
715
+ "reason": reason,
716
+ }),
717
+ )?;
718
+ }
714
719
  }
715
720
  }
716
721
  }
@@ -1435,3 +1435,21 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
1435
1435
  assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
1436
1436
  assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader-new"));
1437
1437
  }
1438
+
1439
+ // E15 (F4.4 双投修)·源码守卫:report_result 的 direct inject 必须被 `if !outcome.ok` 守为
1440
+ // deliver 失败时的真兜底,绝不在 deliver 成功后无条件再投一次(=用户看到两条同内容回复)。
1441
+ #[test]
1442
+ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
1443
+ let src = include_str!("../results.rs");
1444
+ // 锁:inject_leader_notification_direct 调用点之前必须有 `if !outcome.ok` 门。
1445
+ let gate = "if !outcome.ok {";
1446
+ let inject_call = "match inject_leader_notification_direct(";
1447
+ let gate_pos = src.find(gate);
1448
+ let inject_pos = src.find(inject_call);
1449
+ assert!(gate_pos.is_some(), "E15: direct inject must be gated by `if !outcome.ok` (deliver-fail fallback)");
1450
+ assert!(inject_pos.is_some(), "inject_leader_notification_direct call site must exist (do NOT delete it; #230 fallback)");
1451
+ assert!(
1452
+ gate_pos.unwrap() < inject_pos.unwrap(),
1453
+ "E15: the `if !outcome.ok` gate must precede the direct-inject call (deliver-success must skip inject → leader gets exactly one copy)"
1454
+ );
1455
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.8",
24
- "@team-agent/cli-darwin-x64": "0.3.8",
25
- "@team-agent/cli-linux-x64": "0.3.8"
23
+ "@team-agent/cli-darwin-arm64": "0.3.9",
24
+ "@team-agent/cli-darwin-x64": "0.3.9",
25
+ "@team-agent/cli-linux-x64": "0.3.9"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",