@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.
Files changed (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +20 -4
  4. package/crates/team-agent/src/cli/leader.rs +12 -8
  5. package/crates/team-agent/src/cli/tests/base.rs +14 -0
  6. package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
  7. package/crates/team-agent/src/coordinator/tests/spine.rs +6 -0
  8. package/crates/team-agent/src/coordinator/tick.rs +83 -1
  9. package/crates/team-agent/src/leader/lease.rs +19 -0
  10. package/crates/team-agent/src/leader/rediscover/tests.rs +12 -0
  11. package/crates/team-agent/src/leader/rediscover.rs +2 -0
  12. package/crates/team-agent/src/leader/start.rs +34 -23
  13. package/crates/team-agent/src/leader/tests/identity.rs +22 -0
  14. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -0
  15. package/crates/team-agent/src/lifecycle/launch.rs +35 -0
  16. package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -3
  17. package/crates/team-agent/src/lifecycle/restart/common.rs +75 -0
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +211 -6
  19. package/crates/team-agent/src/lifecycle/restart/selection.rs +51 -14
  20. package/crates/team-agent/src/lifecycle/restart.rs +8 -4
  21. package/crates/team-agent/src/lifecycle/tests/core.rs +89 -15
  22. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +144 -3
  23. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +3 -1
  24. package/crates/team-agent/src/messaging/delivery.rs +83 -2
  25. package/crates/team-agent/src/messaging/results.rs +27 -22
  26. package/crates/team-agent/src/messaging/tests/runtime.rs +108 -0
  27. package/crates/team-agent/src/provider/approvals/parsing.rs +43 -14
  28. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +12 -9
  29. package/crates/team-agent/src/transport/test_support.rs +12 -1
  30. package/package.json +4 -4
@@ -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]
@@ -869,6 +945,10 @@ const DELEG_ROLE_WORKER2: &str = "---\nname: worker2\nrole: Second Worker\nprovi
869
945
  pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
870
946
  let ws = temp_ws().join("restartteam");
871
947
  std::fs::create_dir_all(ws.join("agents")).unwrap();
948
+ let alpha_rollout = ws.join("alpha-rollout.jsonl");
949
+ let bravo_rollout = ws.join("bravo-rollout.jsonl");
950
+ std::fs::write(&alpha_rollout, "{}\n").unwrap();
951
+ std::fs::write(&bravo_rollout, "{}\n").unwrap();
872
952
  std::fs::write(ws.join("TEAM.md"), "---\nname: restartteam\nobjective: Restart probe.\nprovider: codex\n---\n\nteam.\n").unwrap();
873
953
  std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
874
954
  std::fs::write(ws.join("agents").join("bravo.md"), DELEG_ROLE_BRAVO).unwrap();
@@ -879,8 +959,8 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
879
959
  &json!({
880
960
  "session_name": "team-restartteam",
881
961
  "agents": {
882
- "alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"},
883
- "bravo": {"status": "running", "provider": "codex", "session_id": "sess-b", "first_send_at": "2026-05-27T10:00:00+00:00"}
962
+ "alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": alpha_rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"},
963
+ "bravo": {"status": "running", "provider": "codex", "session_id": "sess-b", "rollout_path": bravo_rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}
884
964
  }
885
965
  }),
886
966
  )
@@ -889,6 +969,33 @@ pub(super) fn restart_ws_two_resumable_workers() -> PathBuf {
889
969
  ws
890
970
  }
891
971
 
972
+ fn restart_ws_one_resumable_worker() -> PathBuf {
973
+ let ws = temp_ws().join("restartone");
974
+ std::fs::create_dir_all(ws.join("agents")).unwrap();
975
+ let rollout = ws.join("alpha-rollout.jsonl");
976
+ std::fs::write(&rollout, "{}\n").unwrap();
977
+ std::fs::write(
978
+ ws.join("TEAM.md"),
979
+ "---\nname: restartone\nobjective: Restart readiness probe.\nprovider: codex\n---\n\nteam.\n",
980
+ )
981
+ .unwrap();
982
+ std::fs::write(ws.join("agents").join("alpha.md"), DELEG_ROLE_ALPHA).unwrap();
983
+ let spec = crate::compiler::compile_team(&ws).expect("compile 1-agent team");
984
+ std::fs::write(ws.join("team.spec.yaml"), crate::model::yaml::dumps(&spec)).unwrap();
985
+ crate::state::persist::save_runtime_state(
986
+ &ws,
987
+ &json!({
988
+ "session_name": "team-restartone",
989
+ "agents": {
990
+ "alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}
991
+ }
992
+ }),
993
+ )
994
+ .unwrap();
995
+ seed_healthy_coordinator(&ws);
996
+ ws
997
+ }
998
+
892
999
  // 2 [P0] — restart_with_transport must drive the REAL Route-B resume spawn: one spawn per resumable
893
1000
  // worker. The first resumed worker recreates the session with spawn_first; later workers may use
894
1001
  // spawn_into only after a live-session check proves that recreated session still exists. Each spawn
@@ -944,6 +1051,38 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
944
1051
  );
945
1052
  }
946
1053
 
1054
+ #[test]
1055
+ fn restart_times_out_when_spawned_worker_pane_is_not_addressable() {
1056
+ let ws = restart_ws_one_resumable_worker();
1057
+ let transport = OfflineTransport::new().with_spawned_panes_addressable(false);
1058
+
1059
+ let result =
1060
+ restart_with_transport_with_readiness_deadline(&ws, false, None, &transport, Some(0));
1061
+
1062
+ let text = format!("{result:?}");
1063
+ assert!(
1064
+ text.contains("restart not ready")
1065
+ && text.contains("worker pane addressable: no")
1066
+ && text.contains("Action:")
1067
+ && text.contains("Log:"),
1068
+ "restart must refuse with N38 readiness timeout details, not return ok; got {text}"
1069
+ );
1070
+ assert!(
1071
+ !matches!(result, Ok(RestartReport::Restarted { .. })),
1072
+ "restart readiness timeout must not return Restarted ok"
1073
+ );
1074
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1075
+ let timeout = events
1076
+ .iter()
1077
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.readiness_timeout"))
1078
+ .expect("restart.readiness_timeout event");
1079
+ assert_eq!(
1080
+ timeout.get("worker_pane_addressable").and_then(|v| v.as_bool()),
1081
+ Some(false),
1082
+ "timeout event must carry the failed readiness condition: {timeout}"
1083
+ );
1084
+ }
1085
+
947
1086
  // 3 [P0] — start_agent_with_transport on a non-paused agent with a session_id must spawn EXACTLY ONE
948
1087
  // worker (resume) carrying the provider build_command. Today the stub returns RequirementUnmet with
949
1088
  // ZERO spawns -> RED at recorded.len().
@@ -951,11 +1090,13 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
951
1090
  fn start_agent_with_transport_spawns_resume_not_stub() {
952
1091
  let ws = temp_ws().join("startagentws");
953
1092
  std::fs::create_dir_all(&ws).unwrap();
1093
+ let rollout = ws.join("alpha-rollout.jsonl");
1094
+ std::fs::write(&rollout, "{}\n").unwrap();
954
1095
  crate::state::persist::save_runtime_state(
955
1096
  &ws,
956
1097
  &json!({
957
1098
  "session_name": "team-sa",
958
- "agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"}}
1099
+ "agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}}
959
1100
  }),
960
1101
  )
961
1102
  .unwrap();
@@ -88,11 +88,13 @@ impl crate::transport::Transport for SessionProbeRecordingTransport {
88
88
  fn respawn_ws_one_resumable_worker() -> PathBuf {
89
89
  let ws = temp_ws().join("respawn_dead_session");
90
90
  std::fs::create_dir_all(&ws).unwrap();
91
+ let rollout = ws.join("alpha-rollout.jsonl");
92
+ std::fs::write(&rollout, "{}\n").unwrap();
91
93
  crate::state::persist::save_runtime_state(
92
94
  &ws,
93
95
  &json!({
94
96
  "session_name": "team-sa",
95
- "agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "first_send_at": "2026-05-27T10:00:00+00:00"}}
97
+ "agents": {"alpha": {"status": "running", "provider": "codex", "session_id": "sess-a", "rollout_path": rollout.to_string_lossy(), "first_send_at": "2026-05-27T10:00:00+00:00"}}
96
98
  }),
97
99
  )
98
100
  .unwrap();
@@ -17,7 +17,7 @@ use crate::transport::{
17
17
  use super::helpers::{message_exists, MessageStatusShadow};
18
18
  use super::{
19
19
  DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError,
20
- PaneWidthQuery, TrustRetryPayload,
20
+ PaneWidthQuery, TrustRetryPayload, SEND_RETRY_MAX_ATTEMPTS,
21
21
  };
22
22
  use crate::state::projection::OwnerTeamResolution;
23
23
 
@@ -286,7 +286,6 @@ pub fn deliver_pending_message(
286
286
  "submit_unverified:{}",
287
287
  submit_verification_wire(inject_report.submit_verification)
288
288
  );
289
- store.mark(message_id, "submitted_unverified", Some(&reason))?;
290
289
  event_log.write(
291
290
  "send.unverified",
292
291
  serde_json::json!({
@@ -296,6 +295,29 @@ pub fn deliver_pending_message(
296
295
  "attempts": inject_report.attempts,
297
296
  }),
298
297
  )?;
298
+ if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
299
+ store.mark(message_id, "failed", Some("send_unverified_exhausted"))?;
300
+ emit_send_failed_exhausted(
301
+ workspace,
302
+ state,
303
+ event_log,
304
+ message_id,
305
+ &message.recipient,
306
+ inject_report.attempts,
307
+ &reason,
308
+ )?;
309
+ return Ok(DeliveryOutcome {
310
+ ok: false,
311
+ status: DeliveryStatus::Failed,
312
+ message_status: MessageStatusShadow("failed".to_string()),
313
+ message_id: Some(message_id.to_string()),
314
+ verification: Some(reason),
315
+ stage: Some(DeliveryStage::Submit),
316
+ reason: None,
317
+ channel: None,
318
+ });
319
+ }
320
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
299
321
  return Ok(DeliveryOutcome {
300
322
  ok: false,
301
323
  status: DeliveryStatus::Failed,
@@ -538,6 +560,65 @@ fn leader_receiver_field_in_state<'a>(
538
560
  .filter(|value| !value.is_empty())
539
561
  }
540
562
 
563
+ fn emit_send_failed_exhausted(
564
+ workspace: &Path,
565
+ state: &serde_json::Value,
566
+ event_log: &EventLog,
567
+ message_id: &str,
568
+ recipient: &str,
569
+ attempts: u32,
570
+ verification: &str,
571
+ ) -> Result<(), MessagingError> {
572
+ event_log.write(
573
+ "send.failed",
574
+ serde_json::json!({
575
+ "message_id": message_id,
576
+ "recipient": recipient,
577
+ "attempts": attempts,
578
+ "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
579
+ "reason": "send_unverified_exhausted",
580
+ "verification": verification,
581
+ }),
582
+ )?;
583
+ let content = format!(
584
+ "send.failed\nerror: send to {recipient} remained unverified after {attempts}/{SEND_RETRY_MAX_ATTEMPTS} attempts\naction: inspect the target pane and retry the send\nlog: .team/logs/events.jsonl"
585
+ );
586
+ match crate::messaging::send_to_leader_receiver(
587
+ workspace,
588
+ state,
589
+ "leader",
590
+ &content,
591
+ None,
592
+ "coordinator",
593
+ false,
594
+ Some(&format!("send.failed:{message_id}")),
595
+ event_log,
596
+ ) {
597
+ Ok(outcome) => {
598
+ event_log.write(
599
+ "send.failed_notification",
600
+ serde_json::json!({
601
+ "message_id": message_id,
602
+ "recipient": recipient,
603
+ "leader_notification_status": super::helpers::status_wire(outcome.status),
604
+ "leader_message_id": outcome.message_id,
605
+ }),
606
+ )?;
607
+ }
608
+ Err(error) => {
609
+ event_log.write(
610
+ "send.failed_notification_failed",
611
+ serde_json::json!({
612
+ "message_id": message_id,
613
+ "recipient": recipient,
614
+ "error": error.to_string(),
615
+ }),
616
+ )?;
617
+ }
618
+ }
619
+ Ok(())
620
+ }
621
+
541
622
  fn active_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
542
623
  let team = state.get("active_team_key").and_then(serde_json::Value::as_str)?;
543
624
  state
@@ -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
  }
@@ -1068,6 +1068,96 @@ fn fire_due_scheduled_events_fires_each_scheduled_kind() {
1068
1068
  assert_eq!(fired.len(), 3, "exactly the three seeded due events fire, no extras");
1069
1069
  }
1070
1070
 
1071
+ struct UnverifiedInjectTransport;
1072
+ impl Transport for UnverifiedInjectTransport {
1073
+ fn kind(&self) -> BackendKind {
1074
+ BackendKind::Tmux
1075
+ }
1076
+ fn spawn_first(&self, _s: &SessionName, _w: &WindowName, _a: &[String], _c: &Path, _e: &BTreeMap<String, String>) -> Result<SpawnResult, TransportError> {
1077
+ unimplemented!("not reached in delivery")
1078
+ }
1079
+ fn spawn_into(&self, _s: &SessionName, _w: &WindowName, _a: &[String], _c: &Path, _e: &BTreeMap<String, String>) -> Result<SpawnResult, TransportError> {
1080
+ unimplemented!("not reached in delivery")
1081
+ }
1082
+ fn inject(&self, _t: &Target, _p: &InjectPayload, _s: Key, _b: bool) -> Result<InjectReport, TransportError> {
1083
+ Ok(InjectReport {
1084
+ stage_reached: crate::transport::InjectStage::Submit,
1085
+ inject_verification: crate::transport::InjectVerification::CaptureContainsToken,
1086
+ submit_verification: crate::transport::SubmitVerification::PastedContentPromptStillPresentAfterSubmit,
1087
+ turn_verification: crate::transport::TurnVerification::NotYetObserved,
1088
+ attempts: u32::from(SEND_RETRY_MAX_ATTEMPTS),
1089
+ })
1090
+ }
1091
+ fn send_keys(&self, _t: &Target, _k: &[Key]) -> Result<(), TransportError> {
1092
+ Ok(())
1093
+ }
1094
+ fn capture(&self, _t: &Target, range: CaptureRange) -> Result<CapturedText, TransportError> {
1095
+ Ok(CapturedText { text: String::new(), range })
1096
+ }
1097
+ fn query(&self, _t: &Target, _f: PaneField) -> Result<Option<String>, TransportError> {
1098
+ Ok(None)
1099
+ }
1100
+ fn liveness(&self, _p: &PaneId) -> Result<PaneLiveness, TransportError> {
1101
+ Ok(PaneLiveness::Unknown)
1102
+ }
1103
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
1104
+ Ok(Vec::new())
1105
+ }
1106
+ fn has_session(&self, _s: &SessionName) -> Result<bool, TransportError> {
1107
+ Ok(true)
1108
+ }
1109
+ fn list_windows(&self, _s: &SessionName) -> Result<Vec<WindowName>, TransportError> {
1110
+ Ok(Vec::new())
1111
+ }
1112
+ fn set_session_env(&self, _s: &SessionName, _k: &str, _v: &str) -> Result<SetEnvOutcome, TransportError> {
1113
+ Ok(SetEnvOutcome::Applied)
1114
+ }
1115
+ fn kill_session(&self, _s: &SessionName) -> Result<(), TransportError> {
1116
+ Ok(())
1117
+ }
1118
+ fn kill_window(&self, _t: &Target) -> Result<(), TransportError> {
1119
+ Ok(())
1120
+ }
1121
+ fn attach_session(&self, _s: &SessionName) -> Result<AttachOutcome, TransportError> {
1122
+ Ok(AttachOutcome::Attached)
1123
+ }
1124
+ }
1125
+
1126
+ #[test]
1127
+ fn deliver_pending_exhausted_unverified_send_emits_failed_event() {
1128
+ let ws = tmp_ws("sendfailed");
1129
+ let store = store_for(&ws);
1130
+ let log = EventLog::new(&ws);
1131
+ let state = serde_json::json!({
1132
+ "session_name": "team-sendfailed",
1133
+ "leader_receiver": {"pane_id": "%leader"},
1134
+ "agents": {"w1": {"provider": "fake", "pane_id": "%1"}}
1135
+ });
1136
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1137
+ let message_id = store
1138
+ .create_message(None, "leader", "w1", "ping", None, false, None)
1139
+ .unwrap();
1140
+
1141
+ let out = deliver_pending_message(&ws, &store, &UnverifiedInjectTransport, &message_id, &log, &state)
1142
+ .unwrap();
1143
+
1144
+ assert!(!out.ok);
1145
+ assert_eq!(out.message_status.0, "failed");
1146
+ let events = log.tail(0).unwrap();
1147
+ assert!(
1148
+ events
1149
+ .iter()
1150
+ .any(|event| event.get("event").and_then(serde_json::Value::as_str) == Some("send.failed")),
1151
+ "exhausted unverified send must emit send.failed; got {events:?}"
1152
+ );
1153
+ assert!(
1154
+ events
1155
+ .iter()
1156
+ .any(|event| event.get("event").and_then(serde_json::Value::as_str) == Some("send.failed_notification")),
1157
+ "exhausted unverified send must queue a leader-visible notification; got {events:?}"
1158
+ );
1159
+ }
1160
+
1071
1161
  // ════════════════════════════════════════════════════════════════════════
1072
1162
  // GROUP V — retry_result_deliveries: re-route notify_failed watchers with
1073
1163
  // dedupe_reason rebind_retry. result_delivery.py:19-35.
@@ -1435,3 +1525,21 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
1435
1525
  assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
1436
1526
  assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader-new"));
1437
1527
  }
1528
+
1529
+ // E15 (F4.4 双投修)·源码守卫:report_result 的 direct inject 必须被 `if !outcome.ok` 守为
1530
+ // deliver 失败时的真兜底,绝不在 deliver 成功后无条件再投一次(=用户看到两条同内容回复)。
1531
+ #[test]
1532
+ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
1533
+ let src = include_str!("../results.rs");
1534
+ // 锁:inject_leader_notification_direct 调用点之前必须有 `if !outcome.ok` 门。
1535
+ let gate = "if !outcome.ok {";
1536
+ let inject_call = "match inject_leader_notification_direct(";
1537
+ let gate_pos = src.find(gate);
1538
+ let inject_pos = src.find(inject_call);
1539
+ assert!(gate_pos.is_some(), "E15: direct inject must be gated by `if !outcome.ok` (deliver-fail fallback)");
1540
+ assert!(inject_pos.is_some(), "inject_leader_notification_direct call site must exist (do NOT delete it; #230 fallback)");
1541
+ assert!(
1542
+ gate_pos.unwrap() < inject_pos.unwrap(),
1543
+ "E15: the `if !outcome.ok` gate must precede the direct-inject call (deliver-success must skip inject → leader gets exactly one copy)"
1544
+ );
1545
+ }