@team-agent/installer 0.3.14 → 0.3.16

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 (46) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/emit.rs +92 -11
  5. package/crates/team-agent/src/cli/leader.rs +2 -0
  6. package/crates/team-agent/src/cli/mod.rs +255 -37
  7. package/crates/team-agent/src/cli/send.rs +252 -0
  8. package/crates/team-agent/src/cli/status.rs +5 -1
  9. package/crates/team-agent/src/cli/status_port.rs +24 -0
  10. package/crates/team-agent/src/cli/tests/base.rs +21 -3
  11. package/crates/team-agent/src/cli/tests/divergence.rs +6 -6
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  13. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +285 -2
  14. package/crates/team-agent/src/cli/tests/status_send.rs +25 -0
  15. package/crates/team-agent/src/cli/types.rs +32 -1
  16. package/crates/team-agent/src/compiler/tests.rs +2 -2
  17. package/crates/team-agent/src/compiler.rs +1 -1
  18. package/crates/team-agent/src/fake_worker.rs +32 -145
  19. package/crates/team-agent/src/leader/start.rs +272 -8
  20. package/crates/team-agent/src/leader/tests/basics.rs +1 -0
  21. package/crates/team-agent/src/leader/tests/identity.rs +121 -25
  22. package/crates/team-agent/src/leader/types.rs +9 -0
  23. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  24. package/crates/team-agent/src/lifecycle/launch.rs +631 -70
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  27. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +79 -4
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  29. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +731 -8
  30. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  32. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  33. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  34. package/crates/team-agent/src/messaging/mod.rs +6 -3
  35. package/crates/team-agent/src/messaging/results.rs +240 -158
  36. package/crates/team-agent/src/messaging/send.rs +3 -2
  37. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  38. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  39. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  40. package/crates/team-agent/src/os_probe.rs +11 -0
  41. package/crates/team-agent/src/state/persist.rs +6 -0
  42. package/crates/team-agent/src/tmux_backend.rs +85 -0
  43. package/crates/team-agent/src/transport/test_support.rs +46 -1
  44. package/crates/team-agent/src/transport.rs +27 -0
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +277 -0
@@ -6,14 +6,13 @@ use rusqlite::params;
6
6
 
7
7
  use crate::event_log::EventLog;
8
8
  use crate::message_store::MessageStore;
9
- use crate::transport::{InjectPayload, Key, PaneId, Target, Transport};
10
9
 
11
10
  use super::helpers::{next_result_id, required_str, validate_result_envelope};
12
11
  use super::types::SEND_RETRY_MAX_ATTEMPTS;
13
- use crate::model::ids::TaskId;
14
- use crate::state::projection::OwnerTeamResolution;
15
12
  use super::watchers::retry_result_deliveries;
16
13
  use super::MessagingError;
14
+ use crate::model::ids::TaskId;
15
+ use crate::state::projection::OwnerTeamResolution;
17
16
 
18
17
  /// `collect` (`results.py:45`):投递 pending、捞 uncollected results、校验 envelope、更新任务态、
19
18
  /// 写 team_state、ensure coordinator。CLI `collect` + coordinator tick 调。
@@ -43,12 +42,18 @@ fn collect_scoped(
43
42
  let paths = collect_paths(workspace)?;
44
43
  let log = EventLog::new(&paths.run_workspace);
45
44
  let resolved_owner_team_id = match owner_team_id.filter(|team| !team.is_empty()) {
46
- Some(team) => Some(resolve_owner_team_for_read(&paths.run_workspace, team, Some(&log))?),
45
+ Some(team) => Some(resolve_owner_team_for_read(
46
+ &paths.run_workspace,
47
+ team,
48
+ Some(&log),
49
+ )?),
47
50
  None => None,
48
51
  };
49
52
  let owner_team_id = resolved_owner_team_id.as_deref();
50
53
  let mut state = match owner_team_id {
51
- Some(team) => crate::state::projection::select_runtime_state(&paths.run_workspace, Some(team))?,
54
+ Some(team) => {
55
+ crate::state::projection::select_runtime_state(&paths.run_workspace, Some(team))?
56
+ }
52
57
  None => crate::state::persist::load_runtime_state(&paths.run_workspace)?,
53
58
  };
54
59
  let spec_workspace = owner_team_id
@@ -56,7 +61,10 @@ fn collect_scoped(
56
61
  .unwrap_or_else(|| paths.spec_workspace.clone());
57
62
  let spec_path = spec_workspace.join("team.spec.yaml");
58
63
  if !spec_path.exists() {
59
- return Err(MessagingError::Validation(format!("Cannot read {}", spec_path.display())));
64
+ return Err(MessagingError::Validation(format!(
65
+ "Cannot read {}",
66
+ spec_path.display()
67
+ )));
60
68
  }
61
69
  let store = MessageStore::open(&paths.run_workspace)?;
62
70
  let conn = crate::db::schema::open_db(store.db_path())?;
@@ -79,20 +87,20 @@ fn collect_scoped(
79
87
  };
80
88
  let mut stmt = conn.prepare(sql)?;
81
89
  let row_mapper = |row: &rusqlite::Row<'_>| {
82
- Ok(StoredResult {
83
- result_id: row.get(0)?,
84
- task_id: row.get(1)?,
85
- agent_id: row.get(2)?,
86
- envelope: row.get(3)?,
87
- status: row.get(4)?,
88
- created_at: row.get(5)?,
89
- })
90
- };
90
+ Ok(StoredResult {
91
+ result_id: row.get(0)?,
92
+ task_id: row.get(1)?,
93
+ agent_id: row.get(2)?,
94
+ envelope: row.get(3)?,
95
+ status: row.get(4)?,
96
+ created_at: row.get(5)?,
97
+ })
98
+ };
91
99
  let rows = match owner_team_id {
92
100
  Some(team) => stmt.query_map(params![team], row_mapper),
93
101
  None => stmt.query_map([], row_mapper),
94
102
  }?
95
- .collect::<Result<Vec<_>, _>>()?;
103
+ .collect::<Result<Vec<_>, _>>()?;
96
104
  drop(stmt);
97
105
 
98
106
  let mut collected = Vec::new();
@@ -225,7 +233,9 @@ fn ensure_coordinator_after_collect(
225
233
  let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
226
234
  let coordinator = match crate::coordinator::start_coordinator(&workspace_path) {
227
235
  Ok(report) => start_report_value(&report),
228
- Err(e) => serde_json::json!({"ok": false, "status": "start_failed", "error": e.to_string()}),
236
+ Err(e) => {
237
+ serde_json::json!({"ok": false, "status": "start_failed", "error": e.to_string()})
238
+ }
229
239
  };
230
240
  let _ = log.write(
231
241
  "collect.coordinator_checked",
@@ -289,19 +299,18 @@ fn resolve_owner_team_for_read(
289
299
  let state = crate::state::persist::load_runtime_state(workspace)?;
290
300
  match crate::state::projection::resolve_owner_team_id(&state, requested) {
291
301
  OwnerTeamResolution::Canonical(canonical) => Ok(canonical),
292
- OwnerTeamResolution::LegacyAlias { requested, canonical } => {
302
+ OwnerTeamResolution::LegacyAlias {
303
+ requested,
304
+ canonical,
305
+ } => {
293
306
  crate::messaging::delivery::normalize_owner_team_id_rows(
294
- workspace,
295
- &requested,
296
- &canonical,
297
- None,
298
- event_log,
307
+ workspace, &requested, &canonical, None, event_log,
299
308
  )?;
300
309
  Ok(canonical)
301
310
  }
302
- OwnerTeamResolution::Unresolved { requested } => {
303
- Err(MessagingError::Routing(format!("owner_team_unresolved: {requested}")))
304
- }
311
+ OwnerTeamResolution::Unresolved { requested } => Err(MessagingError::Routing(format!(
312
+ "owner_team_unresolved: {requested}"
313
+ ))),
305
314
  OwnerTeamResolution::Ambiguous { requested, matches } => {
306
315
  Err(MessagingError::Routing(format!(
307
316
  "owner_team_ambiguous: {requested} matches {}",
@@ -431,7 +440,10 @@ struct StoredResult {
431
440
  }
432
441
 
433
442
  fn mark_task_done(state: &mut serde_json::Value, task_id: &str, result_id: &str) {
434
- let Some(tasks) = state.get_mut("tasks").and_then(serde_json::Value::as_array_mut) else {
443
+ let Some(tasks) = state
444
+ .get_mut("tasks")
445
+ .and_then(serde_json::Value::as_array_mut)
446
+ else {
435
447
  return;
436
448
  };
437
449
  for task in tasks {
@@ -439,7 +451,10 @@ fn mark_task_done(state: &mut serde_json::Value, task_id: &str, result_id: &str)
439
451
  continue;
440
452
  }
441
453
  if let Some(obj) = task.as_object_mut() {
442
- obj.insert("status".to_string(), serde_json::Value::String("done".to_string()));
454
+ obj.insert(
455
+ "status".to_string(),
456
+ serde_json::Value::String("done".to_string()),
457
+ );
443
458
  obj.insert(
444
459
  "accepted_result_id".to_string(),
445
460
  serde_json::Value::String(result_id.to_string()),
@@ -507,7 +522,8 @@ fn result_counts(
507
522
  }
508
523
  };
509
524
  let mut stmt = conn.prepare(sql)?;
510
- let row_mapper = |row: &rusqlite::Row<'_>| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?));
525
+ let row_mapper =
526
+ |row: &rusqlite::Row<'_>| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?));
511
527
  let rows = match owner_team_id {
512
528
  Some(team) => stmt.query_map(params![team], row_mapper),
513
529
  None => stmt.query_map([], row_mapper),
@@ -564,6 +580,29 @@ pub fn report_result_for_owner_team(
564
580
  workspace: &Path,
565
581
  envelope: &serde_json::Value,
566
582
  explicit_owner_team: Option<&str>,
583
+ ) -> Result<serde_json::Value, MessagingError> {
584
+ report_result_for_owner_team_inner(workspace, envelope, explicit_owner_team, None)
585
+ }
586
+
587
+ pub fn report_result_for_owner_team_with_primary_error(
588
+ workspace: &Path,
589
+ envelope: &serde_json::Value,
590
+ explicit_owner_team: Option<&str>,
591
+ fallback_primary_error: Option<&str>,
592
+ ) -> Result<serde_json::Value, MessagingError> {
593
+ report_result_for_owner_team_inner(
594
+ workspace,
595
+ envelope,
596
+ explicit_owner_team,
597
+ fallback_primary_error,
598
+ )
599
+ }
600
+
601
+ fn report_result_for_owner_team_inner(
602
+ workspace: &Path,
603
+ envelope: &serde_json::Value,
604
+ explicit_owner_team: Option<&str>,
605
+ fallback_primary_error: Option<&str>,
567
606
  ) -> Result<serde_json::Value, MessagingError> {
568
607
  validate_result_envelope(envelope)?;
569
608
  let store = MessageStore::open(workspace)?;
@@ -577,12 +616,15 @@ pub fn report_result_for_owner_team(
577
616
  let mut stored = envelope.clone();
578
617
  if stored.get("result_id").is_none() {
579
618
  if let Some(obj) = stored.as_object_mut() {
580
- obj.insert("result_id".to_string(), serde_json::Value::String(result_id.clone()));
619
+ obj.insert(
620
+ "result_id".to_string(),
621
+ serde_json::Value::String(result_id.clone()),
622
+ );
581
623
  }
582
624
  }
583
625
  let conn = crate::db::schema::open_db(store.db_path())?;
584
- let state_for_owner = crate::state::persist::load_runtime_state(workspace)
585
- .unwrap_or(serde_json::json!({}));
626
+ let state_for_owner =
627
+ crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
586
628
  let owner_team = explicit_owner_team
587
629
  .filter(|team| !team.is_empty())
588
630
  .map(str::to_string)
@@ -612,12 +654,27 @@ pub fn report_result_for_owner_team(
612
654
  "status".to_string(),
613
655
  serde_json::Value::String("duplicate_ignored".to_string()),
614
656
  );
615
- out.insert("result_id".to_string(), serde_json::Value::String(result_id));
616
- out.insert("task_id".to_string(), serde_json::Value::String(task_id.to_string()));
617
- out.insert("agent_id".to_string(), serde_json::Value::String(agent_id.to_string()));
657
+ out.insert(
658
+ "result_id".to_string(),
659
+ serde_json::Value::String(result_id),
660
+ );
661
+ out.insert(
662
+ "task_id".to_string(),
663
+ serde_json::Value::String(task_id.to_string()),
664
+ );
665
+ out.insert(
666
+ "agent_id".to_string(),
667
+ serde_json::Value::String(agent_id.to_string()),
668
+ );
618
669
  out.insert("acknowledged_messages".to_string(), serde_json::json!([]));
619
- out.insert("leader_notified".to_string(), serde_json::Value::Bool(false));
620
- out.insert("notification_message_id".to_string(), serde_json::Value::Null);
670
+ out.insert(
671
+ "leader_notified".to_string(),
672
+ serde_json::Value::Bool(false),
673
+ );
674
+ out.insert(
675
+ "notification_message_id".to_string(),
676
+ serde_json::Value::Null,
677
+ );
621
678
  out.insert(
622
679
  "notification_status".to_string(),
623
680
  serde_json::Value::String("duplicate_ignored".to_string()),
@@ -633,10 +690,11 @@ pub fn report_result_for_owner_team(
633
690
  // primitive synchronously, NOT via a parallel queued scheduled_events row. The
634
691
  // legacy path was MUST-8 / I-3 violating (the deferred notification status was returned
635
692
  // to the caller as "success" while leader actually never saw the result text).
636
- let content = format_report_result_notification(&result_id, task_id, agent_id, status, envelope);
693
+ let content =
694
+ format_report_result_notification(&result_id, task_id, agent_id, status, envelope);
637
695
  let state = report_owner_state(&state_for_owner, &owner_team);
638
696
  let event_log = EventLog::new(workspace);
639
- let mut outcome = super::leader_receiver::send_to_leader_receiver(
697
+ let mut outcome = match super::leader_receiver::send_to_leader_receiver(
640
698
  workspace,
641
699
  &state,
642
700
  "leader",
@@ -646,37 +704,77 @@ pub fn report_result_for_owner_team(
646
704
  false,
647
705
  Some(&result_id),
648
706
  &event_log,
649
- )?;
707
+ ) {
708
+ Ok(outcome) => outcome,
709
+ Err(error) => {
710
+ let message_id = format!("fallback:{result_id}");
711
+ let fallback_error =
712
+ fallback_primary_error_text(fallback_primary_error, format!("leader_funnel_error:{error}"));
713
+ super::leader_receiver::deliver_to_leader_fallback_pane(
714
+ workspace,
715
+ &state,
716
+ &message_id,
717
+ Some(&result_id),
718
+ &content,
719
+ false,
720
+ Some(&fallback_error),
721
+ &event_log,
722
+ )?
723
+ }
724
+ };
650
725
  if let Some(message_id) = outcome.message_id.clone() {
651
- let store = MessageStore::open(workspace)?;
652
- let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
653
- let delivery_state_raw = crate::state::persist::load_runtime_state(workspace)
654
- .unwrap_or_else(|_| state_for_owner.clone());
655
- let delivery_state = report_owner_state(&delivery_state_raw, &owner_team);
656
- for attempt in 0..3 {
657
- let _ = store.mark(&message_id, "accepted", None);
658
- outcome = super::delivery::deliver_pending_message(
659
- workspace,
660
- &store,
661
- &transport,
662
- &message_id,
663
- &event_log,
664
- &delivery_state,
665
- )?;
666
- if outcome.ok {
726
+ let store = MessageStore::open(workspace)?;
727
+ let transport = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
728
+ let delivery_state_raw = crate::state::persist::load_runtime_state(workspace)
729
+ .unwrap_or_else(|_| state_for_owner.clone());
730
+ let delivery_state = report_owner_state(&delivery_state_raw, &owner_team);
731
+ let mut primary_error: Option<String> = None;
732
+ for attempt in 0..3 {
733
+ let _ = store.mark(&message_id, "accepted", None);
734
+ match super::delivery::deliver_pending_message(
735
+ workspace,
736
+ &store,
737
+ &transport,
738
+ &message_id,
739
+ &event_log,
740
+ &delivery_state,
741
+ ) {
742
+ Ok(next) => outcome = next,
743
+ Err(error) => {
744
+ primary_error = Some(format!("primary_delivery_error:{error}"));
745
+ outcome = crate::messaging::DeliveryOutcome {
746
+ ok: false,
747
+ status: crate::messaging::DeliveryStatus::Failed,
748
+ message_status: super::helpers::MessageStatusShadow("failed".to_string()),
749
+ message_id: Some(message_id.clone()),
750
+ verification: Some(error.to_string()),
751
+ stage: None,
752
+ reason: None,
753
+ channel: Some("leader_receiver".to_string()),
754
+ };
667
755
  break;
668
756
  }
669
- let delivered = super::delivery::deliver_pending_messages(
670
- workspace,
671
- &delivery_state,
672
- &transport,
673
- &event_log,
674
- )?;
675
- if delivered.iter().any(|delivered_id| delivered_id == &message_id) {
757
+ }
758
+ if outcome.ok {
759
+ break;
760
+ }
761
+ match super::delivery::deliver_pending_messages(
762
+ workspace,
763
+ &delivery_state,
764
+ &transport,
765
+ &event_log,
766
+ ) {
767
+ Ok(delivered)
768
+ if delivered
769
+ .iter()
770
+ .any(|delivered_id| delivered_id == &message_id) =>
771
+ {
676
772
  outcome = crate::messaging::DeliveryOutcome {
677
773
  ok: true,
678
774
  status: crate::messaging::DeliveryStatus::Delivered,
679
- message_status: super::helpers::MessageStatusShadow("delivered".to_string()),
775
+ message_status: super::helpers::MessageStatusShadow(
776
+ "delivered".to_string(),
777
+ ),
680
778
  message_id: Some(message_id.clone()),
681
779
  verification: None,
682
780
  stage: None,
@@ -685,39 +783,46 @@ pub fn report_result_for_owner_team(
685
783
  };
686
784
  break;
687
785
  }
688
- if attempt < 2 {
689
- std::thread::sleep(std::time::Duration::from_millis(50));
786
+ Ok(_) => {}
787
+ Err(error) => {
788
+ primary_error = Some(format!("primary_delivery_error:{error}"));
789
+ outcome = crate::messaging::DeliveryOutcome {
790
+ ok: false,
791
+ status: crate::messaging::DeliveryStatus::Failed,
792
+ message_status: super::helpers::MessageStatusShadow("failed".to_string()),
793
+ message_id: Some(message_id.clone()),
794
+ verification: Some(error.to_string()),
795
+ stage: None,
796
+ reason: None,
797
+ channel: Some("leader_receiver".to_string()),
798
+ };
799
+ break;
690
800
  }
691
801
  }
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
- }
719
- }
802
+ if attempt < 2 {
803
+ std::thread::sleep(std::time::Duration::from_millis(50));
720
804
  }
805
+ }
806
+ if !outcome.ok {
807
+ let fallback_error = primary_error.unwrap_or_else(|| {
808
+ format!(
809
+ "leader_notification_primary_failed:{}",
810
+ super::helpers::status_wire(outcome.status)
811
+ )
812
+ });
813
+ let fallback_error =
814
+ fallback_primary_error_text(fallback_primary_error, fallback_error);
815
+ outcome = super::leader_receiver::deliver_to_leader_fallback_pane(
816
+ workspace,
817
+ &delivery_state,
818
+ &message_id,
819
+ Some(&result_id),
820
+ &content,
821
+ false,
822
+ Some(&fallback_error),
823
+ &event_log,
824
+ )?;
825
+ }
721
826
  }
722
827
  let leader_notified = outcome.ok;
723
828
  let notification_status_wire = if outcome.ok {
@@ -729,7 +834,10 @@ pub fn report_result_for_owner_team(
729
834
  } else {
730
835
  "refused"
731
836
  };
732
- let channel = outcome.channel.clone().unwrap_or_else(|| "leader_receiver".to_string());
837
+ let channel = outcome
838
+ .channel
839
+ .clone()
840
+ .unwrap_or_else(|| "leader_receiver".to_string());
733
841
  event_log.write(
734
842
  "mcp.report_result",
735
843
  serde_json::json!({
@@ -744,14 +852,28 @@ pub fn report_result_for_owner_team(
744
852
 
745
853
  let mut out = serde_json::Map::new();
746
854
  out.insert("ok".to_string(), serde_json::Value::Bool(true));
747
- out.insert("result_id".to_string(), serde_json::Value::String(result_id));
748
- out.insert("task_id".to_string(), serde_json::Value::String(task_id.to_string()));
749
- out.insert("agent_id".to_string(), serde_json::Value::String(agent_id.to_string()));
855
+ out.insert(
856
+ "result_id".to_string(),
857
+ serde_json::Value::String(result_id),
858
+ );
859
+ out.insert(
860
+ "task_id".to_string(),
861
+ serde_json::Value::String(task_id.to_string()),
862
+ );
863
+ out.insert(
864
+ "agent_id".to_string(),
865
+ serde_json::Value::String(agent_id.to_string()),
866
+ );
750
867
  out.insert("acknowledged_messages".to_string(), serde_json::json!([]));
751
- out.insert("leader_notified".to_string(), serde_json::Value::Bool(leader_notified));
868
+ out.insert(
869
+ "leader_notified".to_string(),
870
+ serde_json::Value::Bool(leader_notified),
871
+ );
752
872
  out.insert(
753
873
  "notification_message_id".to_string(),
754
- outcome.message_id.map_or(serde_json::Value::Null, serde_json::Value::String),
874
+ outcome
875
+ .message_id
876
+ .map_or(serde_json::Value::Null, serde_json::Value::String),
755
877
  );
756
878
  out.insert(
757
879
  "notification_status".to_string(),
@@ -764,20 +886,28 @@ pub fn report_result_for_owner_team(
764
886
  if channel == "rebind_required" {
765
887
  out.insert(
766
888
  "notification_action".to_string(),
767
- serde_json::Value::String("run team-agent claim-leader or team-agent takeover".to_string()),
889
+ serde_json::Value::String(
890
+ "run team-agent claim-leader or team-agent takeover".to_string(),
891
+ ),
768
892
  );
769
893
  }
770
894
  out.insert("notification_event_id".to_string(), serde_json::Value::Null);
771
895
  Ok(serde_json::Value::Object(out))
772
896
  }
773
897
 
898
+ fn fallback_primary_error_text(cli_primary_error: Option<&str>, observed: String) -> String {
899
+ match cli_primary_error.filter(|error| !error.trim().is_empty()) {
900
+ Some(error) => format!("{error}; {observed}"),
901
+ None => observed,
902
+ }
903
+ }
904
+
774
905
  fn report_owner_state(state: &serde_json::Value, owner_team: &str) -> serde_json::Value {
775
- let mut state = match crate::state::projection::resolve_owner_team_id(state, owner_team)
776
- .canonical_key()
777
- {
778
- Some(team) => crate::state::projection::project_top_level_view(state, team),
779
- None => state.clone(),
780
- };
906
+ let mut state =
907
+ match crate::state::projection::resolve_owner_team_id(state, owner_team).canonical_key() {
908
+ Some(team) => crate::state::projection::project_top_level_view(state, team),
909
+ None => state.clone(),
910
+ };
781
911
  if let Some(obj) = state.as_object_mut() {
782
912
  obj.insert(
783
913
  "active_team_key".to_string(),
@@ -787,56 +917,6 @@ fn report_owner_state(state: &serde_json::Value, owner_team: &str) -> serde_json
787
917
  state
788
918
  }
789
919
 
790
- fn inject_leader_notification_direct(
791
- workspace: &Path,
792
- state: &serde_json::Value,
793
- content: &str,
794
- message_id: &str,
795
- ) -> Result<(), String> {
796
- let Some(pane_id) = state
797
- .get("leader_receiver")
798
- .or_else(|| state.get("team_owner"))
799
- .and_then(|receiver| receiver.get("pane_id"))
800
- .and_then(serde_json::Value::as_str)
801
- .filter(|pane| !pane.is_empty() && *pane != "__team_agent_unbound__")
802
- else {
803
- return Err("leader_direct_inject_failed:no_bound_pane".to_string());
804
- };
805
- let rendered = format!(
806
- "Team Agent message from leader_receiver:\n\n{content}\n\n[team-agent-token:{message_id}]"
807
- );
808
- let target = Target::Pane(PaneId::new(pane_id));
809
- if let Some(socket) = state
810
- .get("leader_receiver")
811
- .and_then(|receiver| receiver.get("tmux_socket"))
812
- .and_then(serde_json::Value::as_str)
813
- .filter(|socket| !socket.is_empty())
814
- {
815
- let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
816
- if backend
817
- .inject(&target, &InjectPayload::Text(rendered.clone()), Key::Enter, true)
818
- .is_ok()
819
- {
820
- return Ok(());
821
- }
822
- }
823
- let workspace_backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
824
- if workspace_backend
825
- .inject(&target, &InjectPayload::Text(rendered.clone()), Key::Enter, true)
826
- .is_ok()
827
- {
828
- return Ok(());
829
- }
830
- let default_backend = crate::tmux_backend::TmuxBackend::new();
831
- if default_backend
832
- .inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
833
- .is_ok()
834
- {
835
- return Ok(());
836
- }
837
- Err(format!("leader_direct_inject_failed:pane={pane_id}"))
838
- }
839
-
840
920
  fn insert_result_if_absent(
841
921
  conn: &rusqlite::Connection,
842
922
  result_id: &str,
@@ -888,7 +968,9 @@ fn format_report_result_notification(
888
968
  }
889
969
 
890
970
  fn format_report_result_tests(envelope: &serde_json::Value) -> Option<String> {
891
- let tests = envelope.get("tests").and_then(serde_json::Value::as_array)?;
971
+ let tests = envelope
972
+ .get("tests")
973
+ .and_then(serde_json::Value::as_array)?;
892
974
  let parts = tests
893
975
  .iter()
894
976
  .filter_map(|test| {
@@ -9,7 +9,7 @@ use crate::model::ids::{TaskId, TeamKey};
9
9
  use crate::transport::{PaneId, Transport};
10
10
 
11
11
  use super::helpers::{status_wire, MessageStatusShadow};
12
- use super::leader_receiver::send_to_leader_receiver;
12
+ use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_message_id};
13
13
  use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
14
14
 
15
15
  /// 发件目标:单 target / 广播 `*` / 扇出 list (`send.py:36` `target: str|list[str]|None`)。
@@ -89,7 +89,7 @@ pub fn send_message(
89
89
  backfill_leader_binding_for_delivery_view(&mut state, &raw_state);
90
90
  let recipient = match target {
91
91
  MessageTarget::Single(target) if target == "leader" => {
92
- let outcome = send_to_leader_receiver(
92
+ let outcome = send_to_leader_receiver_with_message_id(
93
93
  workspace,
94
94
  &state,
95
95
  "leader",
@@ -98,6 +98,7 @@ pub fn send_message(
98
98
  &opts.sender,
99
99
  opts.requires_ack,
100
100
  None,
101
+ opts.message_id.as_deref(),
101
102
  &event_log,
102
103
  )?;
103
104
  if matches!(outcome.status, DeliveryStatus::Queued) && owner_pane_is_dead(&state) {
@@ -0,0 +1,35 @@
1
+ #[test]
2
+ fn e23_fallback_pane_has_single_shared_noisy_surface() {
3
+ let leader_receiver = include_str!("../leader_receiver.rs");
4
+ assert!(leader_receiver.contains("deliver_to_leader_fallback_pane"));
5
+ assert!(leader_receiver.contains("leader_receiver.fallback_pane_attempt"));
6
+ assert!(leader_receiver.contains("leader_receiver.fallback_pane_submitted"));
7
+ assert!(leader_receiver.contains("leader_receiver.fallback_pane_failed"));
8
+ assert!(leader_receiver.contains("delivered_via=fallback_pane"));
9
+ assert!(
10
+ leader_receiver.find("if primary_ok").unwrap()
11
+ < leader_receiver
12
+ .find("leader_receiver.fallback_pane_attempt")
13
+ .unwrap(),
14
+ "primary-ok guard must run before any fallback audit/inject attempt"
15
+ );
16
+ }
17
+
18
+ #[test]
19
+ fn e23_no_parallel_direct_inject_or_fake_worker_mirror() {
20
+ let results = include_str!("../results.rs");
21
+ assert!(
22
+ !results.contains("inject_leader_notification_direct"),
23
+ "report_result must use the shared fallback pane primitive, not a private direct-inject path"
24
+ );
25
+
26
+ let fake_worker = include_str!("../../fake_worker.rs");
27
+ assert!(
28
+ !fake_worker.contains("mirror_fake_result_to_leader"),
29
+ "fake_worker must not keep an independent leader pane mirror"
30
+ );
31
+ assert!(
32
+ !fake_worker.contains("Transport::inject"),
33
+ "fake_worker must report through report_result_for_owner_team, not inject leader panes directly"
34
+ );
35
+ }
@@ -275,7 +275,10 @@ impl EnvGuardMsg {
275
275
  Some(v) => std::env::set_var(key, v),
276
276
  None => std::env::remove_var(key),
277
277
  }
278
- Self { key: key.to_string(), prev }
278
+ Self {
279
+ key: key.to_string(),
280
+ prev,
281
+ }
279
282
  }
280
283
  }
281
284
  impl Drop for EnvGuardMsg {
@@ -288,6 +291,7 @@ impl Drop for EnvGuardMsg {
288
291
  }
289
292
 
290
293
  mod basic;
294
+ mod e23;
295
+ mod main_preserved;
291
296
  mod runtime;
292
297
  mod spine;
293
- mod main_preserved;