@team-agent/installer 0.3.14 → 0.3.15

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 (36) 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/mod.rs +60 -10
  6. package/crates/team-agent/src/cli/send.rs +252 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
  9. package/crates/team-agent/src/cli/types.rs +30 -1
  10. package/crates/team-agent/src/compiler/tests.rs +2 -2
  11. package/crates/team-agent/src/compiler.rs +1 -1
  12. package/crates/team-agent/src/fake_worker.rs +32 -145
  13. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  14. package/crates/team-agent/src/lifecycle/launch.rs +560 -69
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  16. package/crates/team-agent/src/lifecycle/restart/common.rs +38 -2
  17. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +72 -1
  18. package/crates/team-agent/src/lifecycle/tests/core.rs +14 -5
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +668 -8
  20. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  21. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  22. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  23. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  24. package/crates/team-agent/src/messaging/mod.rs +6 -3
  25. package/crates/team-agent/src/messaging/results.rs +240 -158
  26. package/crates/team-agent/src/messaging/send.rs +3 -2
  27. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  28. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  29. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  30. package/crates/team-agent/src/os_probe.rs +11 -0
  31. package/crates/team-agent/src/state/persist.rs +6 -0
  32. package/crates/team-agent/src/tmux_backend.rs +85 -0
  33. package/crates/team-agent/src/transport/test_support.rs +46 -1
  34. package/crates/team-agent/src/transport.rs +27 -0
  35. package/package.json +4 -4
  36. package/skills/team-agent/references/recovery-runbook.md +277 -0
@@ -413,6 +413,8 @@ pub struct LaunchReport {
413
413
  pub started: Vec<StartedAgent>,
414
414
  /// 是否 dry-run(只解析路由/权限,不起进程)。
415
415
  pub dry_run: bool,
416
+ /// Selected tmux endpoint for the team transport when known.
417
+ pub tmux_endpoint: Option<String>,
416
418
  /// 路由决策(每 task 一条;`routing.decision` 事件)。
417
419
  pub routes: Vec<RoutingDecision>,
418
420
  /// 权限摘要(每 agent 一条)。
@@ -436,6 +438,9 @@ pub struct StartedAgent {
436
438
  pub claude_config_dir: Option<PathBuf>,
437
439
  pub provider_projects_root: Option<PathBuf>,
438
440
  pub managed_mcp_config: bool,
441
+ pub layout_window: Option<WindowName>,
442
+ pub layout_index: Option<usize>,
443
+ pub pane_index: Option<usize>,
439
444
  pub display: WorkerDisplay,
440
445
  }
441
446
 
@@ -22,6 +22,14 @@ Use Team Agent MCP tools for team-visible coordination:
22
22
  - Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
23
23
  doing a low-level compatibility diagnostic. The MCP runtime fills protocol
24
24
  fields from the current worker and task state.
25
+ - Emergency fallback exception: if a leader-bound MCP send/report payload is
26
+ already built but the MCP transport itself fails or the primary delivery path
27
+ errors (for example `Transport closed`, `Connection refused`, `Broken pipe`,
28
+ `EOF`, timeout, or internal delivery error), use `team-agent fallback-send-leader`
29
+ or `team-agent fallback-report-result` exactly once with `--primary-error`.
30
+ Do not use fallback for business refusals such as permission, quota, or unknown
31
+ target. After fallback, tell the leader to run `team-agent restart-agent` to
32
+ refresh the worker MCP transport.
25
33
 
26
34
  If you are blocked or cannot continue, message the leader promptly instead of
27
35
  waiting silently. If work takes several minutes, send a short progress update.
@@ -5,6 +5,7 @@ use std::path::Path;
5
5
 
6
6
  use serde_json::Value;
7
7
 
8
+ use crate::event_log::EventLog;
8
9
  use crate::messaging::MessageTarget;
9
10
 
10
11
  use super::helpers::{json_dumps_default, object_fields};
@@ -172,19 +173,35 @@ fn run_stdio_loop<R: BufRead, W: Write>(
172
173
  ) -> Result<ServerRunReport, McpError> {
173
174
  let tools = TeamOrchestratorTools::new(workspace);
174
175
  let mut report = ServerRunReport::default();
176
+ let marker = McpServerLifecycleMarker::from_env(workspace);
177
+ marker.write_started();
178
+ let result = run_stdio_loop_inner(&tools, reader, &mut writer, &mut report);
179
+ match &result {
180
+ Ok(_) => marker.write_exit("stdin_eof", None),
181
+ Err(error) => marker.write_exit("fatal_error", Some(&error.to_string())),
182
+ }
183
+ result
184
+ }
185
+
186
+ fn run_stdio_loop_inner<R: BufRead, W: Write>(
187
+ tools: &TeamOrchestratorTools,
188
+ reader: R,
189
+ writer: &mut W,
190
+ report: &mut ServerRunReport,
191
+ ) -> Result<ServerRunReport, McpError> {
175
192
  for line in reader.lines() {
176
193
  let line = line?;
177
194
  report.requests_read = report.requests_read.saturating_add(1);
178
- let frame = handle_stdin_line(&tools, &line, &mut report)?;
195
+ let frame = handle_stdin_line(tools, &line, report)?;
179
196
  if let Some(value) = frame {
180
- serde_json::to_writer(&mut writer, &value)?;
197
+ serde_json::to_writer(&mut *writer, &value)?;
181
198
  writer.write_all(b"\n")?;
182
199
  writer.flush()?;
183
200
  report.responses_written = report.responses_written.saturating_add(1);
184
201
  }
185
202
  }
186
203
  report.clean_eof = true;
187
- Ok(report)
204
+ Ok(report.clone())
188
205
  }
189
206
 
190
207
  fn handle_stdin_line(
@@ -247,6 +264,66 @@ fn tool_call_result_value(is_error: bool, body: &Value) -> Value {
247
264
  })
248
265
  }
249
266
 
267
+ struct McpServerLifecycleMarker {
268
+ workspace: std::path::PathBuf,
269
+ agent_id: Option<String>,
270
+ owner_team_id: Option<String>,
271
+ pid: u32,
272
+ ppid: u32,
273
+ }
274
+
275
+ impl McpServerLifecycleMarker {
276
+ fn from_env(workspace: &Path) -> Self {
277
+ Self {
278
+ workspace: workspace.to_path_buf(),
279
+ agent_id: non_empty_env("TEAM_AGENT_ID"),
280
+ owner_team_id: non_empty_env("TEAM_AGENT_OWNER_TEAM_ID"),
281
+ pid: std::process::id(),
282
+ ppid: parent_pid(),
283
+ }
284
+ }
285
+
286
+ fn write_started(&self) {
287
+ self.write("mcp.server_started", None, None);
288
+ }
289
+
290
+ fn write_exit(&self, reason: &str, error: Option<&str>) {
291
+ self.write("mcp.server_exit", Some(reason), error);
292
+ }
293
+
294
+ fn write(&self, event: &str, reason: Option<&str>, error: Option<&str>) {
295
+ let _ = EventLog::new(&self.workspace).write(
296
+ event,
297
+ serde_json::json!({
298
+ "agent_id": self.agent_id.as_deref(),
299
+ "owner_team_id": self.owner_team_id.as_deref(),
300
+ "workspace": self.workspace.display().to_string(),
301
+ "pid": self.pid,
302
+ "ppid": self.ppid,
303
+ "reason": reason,
304
+ "error": error,
305
+ }),
306
+ );
307
+ }
308
+ }
309
+
310
+ fn non_empty_env(key: &str) -> Option<String> {
311
+ std::env::var(key)
312
+ .ok()
313
+ .map(|value| value.trim().to_string())
314
+ .filter(|value| !value.is_empty())
315
+ }
316
+
317
+ #[cfg(unix)]
318
+ fn parent_pid() -> u32 {
319
+ u32::try_from(unsafe { libc::getppid() }).unwrap_or(0)
320
+ }
321
+
322
+ #[cfg(not(unix))]
323
+ fn parent_pid() -> u32 {
324
+ 0
325
+ }
326
+
250
327
  fn error_response_value(id: RpcId, code: i64, message: String) -> Value {
251
328
  let response = RpcResponse {
252
329
  jsonrpc: "2.0".to_string(),
@@ -489,3 +566,76 @@ fn message_target_from_value(value: Option<&Value>) -> MessageTarget {
489
566
  _ => MessageTarget::Single(String::new()),
490
567
  }
491
568
  }
569
+
570
+ #[cfg(test)]
571
+ #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
572
+ mod e23_lifecycle_marker_tests {
573
+ use super::*;
574
+
575
+ fn marker_ws(tag: &str) -> std::path::PathBuf {
576
+ use std::sync::atomic::{AtomicU64, Ordering};
577
+ static N: AtomicU64 = AtomicU64::new(0);
578
+ let path = std::env::temp_dir().join(format!(
579
+ "ta-rs-mcp-marker-{tag}-{}-{}",
580
+ std::process::id(),
581
+ N.fetch_add(1, Ordering::Relaxed)
582
+ ));
583
+ std::fs::create_dir_all(&path).unwrap();
584
+ path
585
+ }
586
+
587
+ #[test]
588
+ fn stdio_loop_writes_started_and_stdin_eof_exit_markers() {
589
+ let ws = marker_ws("eof");
590
+ let input = std::io::Cursor::new(Vec::<u8>::new());
591
+ let mut output = Vec::new();
592
+
593
+ let report = run_stdio_loop(&ws, input, &mut output).unwrap();
594
+
595
+ assert!(report.clean_eof);
596
+ assert!(output.is_empty(), "empty stdin must not write stdout");
597
+ let events = EventLog::new(&ws).tail(0).unwrap();
598
+ assert_eq!(events.len(), 2, "started + stdin_eof exit events");
599
+ assert_eq!(events[0]["event"], serde_json::json!("mcp.server_started"));
600
+ assert_eq!(events[1]["event"], serde_json::json!("mcp.server_exit"));
601
+ assert_eq!(events[1]["reason"], serde_json::json!("stdin_eof"));
602
+ assert_eq!(events[1]["workspace"], serde_json::json!(ws.display().to_string()));
603
+ assert_eq!(events[1]["pid"], serde_json::json!(std::process::id()));
604
+ assert!(events[1].get("ppid").is_some());
605
+ let _ = std::fs::remove_dir_all(&ws);
606
+ }
607
+
608
+ #[test]
609
+ fn stdio_loop_writes_fatal_error_marker_before_returning_io_error() {
610
+ struct FailingWriter;
611
+ impl Write for FailingWriter {
612
+ fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
613
+ Err(std::io::Error::other("simulated stdout failure"))
614
+ }
615
+
616
+ fn flush(&mut self) -> std::io::Result<()> {
617
+ Ok(())
618
+ }
619
+ }
620
+
621
+ let ws = marker_ws("fatal");
622
+ let input = std::io::Cursor::new(
623
+ br#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#.to_vec(),
624
+ );
625
+
626
+ let err = run_stdio_loop(&ws, input, FailingWriter).expect_err("writer failure");
627
+
628
+ assert!(err.to_string().contains("simulated stdout failure"));
629
+ let events = EventLog::new(&ws).tail(0).unwrap();
630
+ assert_eq!(events[0]["event"], serde_json::json!("mcp.server_started"));
631
+ assert_eq!(events[1]["event"], serde_json::json!("mcp.server_exit"));
632
+ assert_eq!(events[1]["reason"], serde_json::json!("fatal_error"));
633
+ assert!(
634
+ events[1]["error"]
635
+ .as_str()
636
+ .unwrap_or_default()
637
+ .contains("simulated stdout failure")
638
+ );
639
+ let _ = std::fs::remove_dir_all(&ws);
640
+ }
641
+ }
@@ -2,15 +2,16 @@
2
2
 
3
3
  use std::path::Path;
4
4
 
5
+ use rusqlite::OptionalExtension;
5
6
  use serde_json::Value;
6
7
 
7
8
  use crate::event_log::EventLog;
8
9
  use crate::message_store::{MessageStore, NotificationClaimParams};
9
10
  use crate::model::ids::{OwnerEpoch, TaskId};
10
- use crate::transport::Transport;
11
+ use crate::transport::{InjectPayload, Key, PaneId, Target, Transport};
11
12
 
12
13
  use super::helpers::MessageStatusShadow;
13
- use super::{DeliveryOutcome, DeliveryStatus, MessagingError};
14
+ use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
14
15
 
15
16
  /// `_send_to_leader_receiver` (`leader.py:69`) — **N31/N32 funnel primitive**:所有 leader-bound
16
17
  /// caller(send_message(to=leader) / report_result / request_human / idle reminder /
@@ -32,6 +33,36 @@ pub fn send_to_leader_receiver(
32
33
  requires_ack: bool,
33
34
  result_id: Option<&str>,
34
35
  event_log: &EventLog,
36
+ ) -> Result<DeliveryOutcome, MessagingError> {
37
+ send_to_leader_receiver_with_message_id(
38
+ workspace,
39
+ state,
40
+ leader_id,
41
+ content,
42
+ task_id,
43
+ sender,
44
+ requires_ack,
45
+ result_id,
46
+ None,
47
+ event_log,
48
+ )
49
+ }
50
+
51
+ /// Same leader funnel as [`send_to_leader_receiver`], but lets the caller supply a
52
+ /// stable message id for transport-fallback retries. The public function above
53
+ /// keeps the existing call surface unchanged.
54
+ #[allow(clippy::too_many_arguments)]
55
+ pub fn send_to_leader_receiver_with_message_id(
56
+ workspace: &Path,
57
+ state: &serde_json::Value,
58
+ leader_id: &str,
59
+ content: &str,
60
+ task_id: Option<&TaskId>,
61
+ sender: &str,
62
+ requires_ack: bool,
63
+ result_id: Option<&str>,
64
+ requested_message_id: Option<&str>,
65
+ event_log: &EventLog,
35
66
  ) -> Result<DeliveryOutcome, MessagingError> {
36
67
  let store = MessageStore::open(workspace)?;
37
68
  let owner_team = active_team_key(workspace, state);
@@ -41,15 +72,40 @@ pub fn send_to_leader_receiver(
41
72
  serde_json::json!({"sender": sender, "leader_id": leader_id, "result_id": result_id}),
42
73
  )?;
43
74
  }
44
- let message_id = store.create_message(
45
- task_id.map(TaskId::as_str),
46
- sender,
47
- leader_id,
48
- content,
49
- None,
50
- false,
51
- Some(&owner_team),
52
- )?;
75
+ let message_id = if let Some(requested) = requested_message_id {
76
+ if store.message_exists(requested)? {
77
+ return Ok(DeliveryOutcome {
78
+ ok: false,
79
+ status: DeliveryStatus::Refused,
80
+ message_status: MessageStatusShadow("refused".to_string()),
81
+ message_id: Some(requested.to_string()),
82
+ verification: None,
83
+ stage: None,
84
+ reason: Some(DeliveryRefusal::Duplicate),
85
+ channel: Some("leader_receiver".to_string()),
86
+ });
87
+ }
88
+ store.create_message_with_id(
89
+ requested,
90
+ task_id.map(TaskId::as_str),
91
+ sender,
92
+ leader_id,
93
+ content,
94
+ None,
95
+ false,
96
+ Some(&owner_team),
97
+ )?
98
+ } else {
99
+ store.create_message(
100
+ task_id.map(TaskId::as_str),
101
+ sender,
102
+ leader_id,
103
+ content,
104
+ None,
105
+ false,
106
+ Some(&owner_team),
107
+ )?
108
+ };
53
109
  // #231 exactly-once across rebind: insert the leader_notification_log PK BEFORE the
54
110
  // unbound-pane check. That way, if the result row gets blocked (I-4) and later the
55
111
  // leader rebinds + reclaim replays this row, the dedup PK still gates any second
@@ -170,7 +226,9 @@ pub fn claim_leader_receiver(
170
226
  }
171
227
  let next_epoch = owner_epoch(state).unwrap_or(0).saturating_add(1);
172
228
  let Some(root) = state.as_object_mut() else {
173
- return Err(MessagingError::Routing("runtime state root is not an object".to_string()));
229
+ return Err(MessagingError::Routing(
230
+ "runtime state root is not an object".to_string(),
231
+ ));
174
232
  };
175
233
  let owner = root
176
234
  .entry("team_owner")
@@ -225,49 +283,199 @@ pub fn mirror_peer_message_to_leader(
225
283
  event_log: &EventLog,
226
284
  ) -> Result<(), MessagingError> {
227
285
  let _ = send_to_leader_receiver(
228
- workspace,
229
- state,
230
- "leader",
231
- content,
232
- task_id,
233
- sender,
234
- false,
235
- None,
236
- event_log,
286
+ workspace, state, "leader", content, task_id, sender, false, None, event_log,
237
287
  )?;
238
288
  let _ = recipient;
239
289
  Ok(())
240
290
  }
241
291
 
292
+ /// E23 transport fallback: physically inject the already-built leader payload into
293
+ /// the bound leader pane only after the caller's primary path is known not to have
294
+ /// succeeded. This primitive is reclaim-neutral: it reads leader_receiver/team_owner
295
+ /// fields but never writes binding state.
296
+ #[allow(clippy::too_many_arguments)]
297
+ pub fn deliver_to_leader_fallback_pane(
298
+ workspace: &Path,
299
+ state: &Value,
300
+ message_id: &str,
301
+ result_id: Option<&str>,
302
+ content: &str,
303
+ primary_ok: bool,
304
+ primary_error: Option<&str>,
305
+ event_log: &EventLog,
306
+ ) -> Result<DeliveryOutcome, MessagingError> {
307
+ if primary_ok {
308
+ return Ok(DeliveryOutcome {
309
+ ok: true,
310
+ status: DeliveryStatus::AlreadyDelivered,
311
+ message_status: MessageStatusShadow("delivered".to_string()),
312
+ message_id: Some(message_id.to_string()),
313
+ verification: None,
314
+ stage: None,
315
+ reason: None,
316
+ channel: Some("leader_receiver".to_string()),
317
+ });
318
+ }
319
+
320
+ let store = MessageStore::open(workspace)?;
321
+ if message_already_delivered(&store, message_id)? {
322
+ return Ok(DeliveryOutcome {
323
+ ok: true,
324
+ status: DeliveryStatus::AlreadyDelivered,
325
+ message_status: MessageStatusShadow("delivered".to_string()),
326
+ message_id: Some(message_id.to_string()),
327
+ verification: None,
328
+ stage: None,
329
+ reason: None,
330
+ channel: Some("leader_receiver".to_string()),
331
+ });
332
+ }
333
+
334
+ let owner_team_id = active_team_key(workspace, state);
335
+ let pane_id = leader_pane_id(state).map(str::to_string);
336
+ let primary_error = primary_error
337
+ .filter(|error| !error.trim().is_empty())
338
+ .unwrap_or("primary delivery failed");
339
+ let attempt_payload = serde_json::json!({
340
+ "message_id": message_id,
341
+ "result_id": result_id,
342
+ "owner_team_id": owner_team_id,
343
+ "pane_id": pane_id,
344
+ "primary_error": primary_error,
345
+ "delivered_via": "fallback_pane",
346
+ });
347
+ event_log.write(
348
+ "leader_receiver.fallback_pane_attempt",
349
+ attempt_payload.clone(),
350
+ )?;
351
+
352
+ let Some(pane_id) = pane_id.filter(|pane| !pane.is_empty() && pane != "__team_agent_unbound__")
353
+ else {
354
+ let failed = serde_json::json!({
355
+ "message_id": message_id,
356
+ "result_id": result_id,
357
+ "owner_team_id": owner_team_id,
358
+ "pane_id": null,
359
+ "primary_error": primary_error,
360
+ "delivered_via": "fallback_pane",
361
+ "reason": "no_bound_pane",
362
+ });
363
+ event_log.write("leader_receiver.fallback_pane_failed", failed)?;
364
+ return Ok(DeliveryOutcome {
365
+ ok: false,
366
+ status: DeliveryStatus::Blocked,
367
+ message_status: MessageStatusShadow("blocked".to_string()),
368
+ message_id: Some(message_id.to_string()),
369
+ verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
370
+ stage: None,
371
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
372
+ channel: Some("fallback_pane".to_string()),
373
+ });
374
+ };
375
+
376
+ let rendered = render_fallback_pane_message(content, message_id, primary_error);
377
+ let target = Target::Pane(PaneId::new(&pane_id));
378
+ let payload = InjectPayload::Text(rendered);
379
+ let inject_result = leader_tmux_socket(state)
380
+ .and_then(|socket| {
381
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
382
+ backend.inject(&target, &payload, Key::Enter, true).ok()
383
+ })
384
+ .map(Ok)
385
+ .unwrap_or_else(|| {
386
+ let backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
387
+ backend.inject(&target, &payload, Key::Enter, true)
388
+ })
389
+ .or_else(|_| {
390
+ let backend = crate::tmux_backend::TmuxBackend::new();
391
+ backend.inject(&target, &payload, Key::Enter, true)
392
+ });
393
+
394
+ match inject_result {
395
+ Ok(report) => {
396
+ store.mark(message_id, "delivered", None)?;
397
+ event_log.write(
398
+ "leader_receiver.fallback_pane_submitted",
399
+ serde_json::json!({
400
+ "message_id": message_id,
401
+ "result_id": result_id,
402
+ "owner_team_id": owner_team_id,
403
+ "pane_id": pane_id,
404
+ "primary_error": primary_error,
405
+ "delivered_via": "fallback_pane",
406
+ "verification": format!("{:?}", report.submit_verification),
407
+ }),
408
+ )?;
409
+ Ok(DeliveryOutcome {
410
+ ok: true,
411
+ status: DeliveryStatus::Delivered,
412
+ message_status: MessageStatusShadow("delivered".to_string()),
413
+ message_id: Some(message_id.to_string()),
414
+ verification: Some("delivered_via=fallback_pane".to_string()),
415
+ stage: None,
416
+ reason: None,
417
+ channel: Some("fallback_pane".to_string()),
418
+ })
419
+ }
420
+ Err(error) => {
421
+ event_log.write(
422
+ "leader_receiver.fallback_pane_failed",
423
+ serde_json::json!({
424
+ "message_id": message_id,
425
+ "result_id": result_id,
426
+ "owner_team_id": owner_team_id,
427
+ "pane_id": pane_id,
428
+ "primary_error": primary_error,
429
+ "delivered_via": "fallback_pane",
430
+ "reason": error.to_string(),
431
+ }),
432
+ )?;
433
+ Ok(DeliveryOutcome {
434
+ ok: false,
435
+ status: DeliveryStatus::Failed,
436
+ message_status: MessageStatusShadow("failed".to_string()),
437
+ message_id: Some(message_id.to_string()),
438
+ verification: Some(error.to_string()),
439
+ stage: None,
440
+ reason: Some(DeliveryRefusal::TmuxTargetMissing),
441
+ channel: Some("fallback_pane".to_string()),
442
+ })
443
+ }
444
+ }
445
+ }
446
+
242
447
  pub(crate) fn active_team_key(workspace: &Path, state: &Value) -> String {
243
448
  state
244
449
  .get("active_team_key")
245
450
  .and_then(Value::as_str)
246
451
  .filter(|team| !team.is_empty())
247
452
  .map(ToString::to_string)
248
- .or_else(|| workspace.file_name().map(|name| name.to_string_lossy().to_string()))
453
+ .or_else(|| {
454
+ workspace
455
+ .file_name()
456
+ .map(|name| name.to_string_lossy().to_string())
457
+ })
249
458
  .unwrap_or_else(|| "current".to_string())
250
459
  }
251
460
 
252
461
  fn owner_epoch(state: &Value) -> Option<u64> {
253
462
  receiver_or_owner_field(state, "team_owner", "owner_epoch")
254
463
  .and_then(Value::as_u64)
255
- .or_else(|| receiver_or_owner_field(state, "leader_receiver", "owner_epoch").and_then(Value::as_u64))
464
+ .or_else(|| {
465
+ receiver_or_owner_field(state, "leader_receiver", "owner_epoch").and_then(Value::as_u64)
466
+ })
256
467
  }
257
468
 
258
469
  fn receiver_or_owner_field<'a>(state: &'a Value, record: &str, field: &str) -> Option<&'a Value> {
259
- state
260
- .get(record)
261
- .and_then(|v| v.get(field))
262
- .or_else(|| {
263
- let active = state.get("active_team_key").and_then(Value::as_str)?;
264
- state
265
- .get("teams")
266
- .and_then(Value::as_object)
267
- .and_then(|teams| teams.get(active))
268
- .and_then(|team| team.get(record))
269
- .and_then(|v| v.get(field))
270
- })
470
+ state.get(record).and_then(|v| v.get(field)).or_else(|| {
471
+ let active = state.get("active_team_key").and_then(Value::as_str)?;
472
+ state
473
+ .get("teams")
474
+ .and_then(Value::as_object)
475
+ .and_then(|teams| teams.get(active))
476
+ .and_then(|team| team.get(record))
477
+ .and_then(|v| v.get(field))
478
+ })
271
479
  }
272
480
 
273
481
  fn leader_record_field<'a>(state: &'a Value, field: &str) -> Option<&'a Value> {
@@ -284,8 +492,7 @@ fn leader_session_uuid(state: &Value) -> Option<&str> {
284
492
  }
285
493
 
286
494
  pub(crate) fn leader_pane_bound_but_not_live(workspace: &Path, state: &Value) -> bool {
287
- leader_pane_id(state)
288
- .is_some_and(|pane_id| !leader_pane_is_live(workspace, state, pane_id))
495
+ leader_pane_id(state).is_some_and(|pane_id| !leader_pane_is_live(workspace, state, pane_id))
289
496
  }
290
497
 
291
498
  fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
@@ -304,7 +511,9 @@ fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
304
511
  .list_targets()
305
512
  .unwrap_or_default(),
306
513
  );
307
- targets.iter().any(|target| target.pane_id.as_str() == pane_id)
514
+ targets
515
+ .iter()
516
+ .any(|target| target.pane_id.as_str() == pane_id)
308
517
  }
309
518
 
310
519
  fn leader_pane_id(state: &Value) -> Option<&str> {
@@ -318,11 +527,35 @@ fn leader_tmux_socket(state: &Value) -> Option<&str> {
318
527
  .filter(|socket| std::path::Path::new(socket).is_absolute())
319
528
  }
320
529
 
321
- fn copy_candidate_field(
322
- out: &mut serde_json::Map<String, Value>,
323
- candidate: &Value,
324
- key: &str,
325
- ) {
530
+ fn message_already_delivered(
531
+ store: &MessageStore,
532
+ message_id: &str,
533
+ ) -> Result<bool, MessagingError> {
534
+ let conn = crate::db::schema::open_db(store.db_path())?;
535
+ let status = conn
536
+ .query_row(
537
+ "select status from messages where message_id = ?1",
538
+ [message_id],
539
+ |row| row.get::<_, String>(0),
540
+ )
541
+ .optional()?;
542
+ Ok(matches!(
543
+ status.as_deref(),
544
+ Some("delivered" | "acknowledged" | "submitted" | "submitted_unverified")
545
+ ))
546
+ }
547
+
548
+ fn render_fallback_pane_message(content: &str, message_id: &str, primary_error: &str) -> String {
549
+ format!(
550
+ "Team Agent fallback delivery\n\
551
+ delivered_via=fallback_pane\n\
552
+ primary_error: {primary_error}\n\n\
553
+ {content}\n\n\
554
+ [team-agent-token:{message_id}]"
555
+ )
556
+ }
557
+
558
+ fn copy_candidate_field(out: &mut serde_json::Map<String, Value>, candidate: &Value, key: &str) {
326
559
  if let Some(value) = candidate.get(key) {
327
560
  out.insert(key.to_string(), value.clone());
328
561
  }
@@ -83,16 +83,19 @@ pub use delivery::{
83
83
  };
84
84
  pub use helpers::fail_leader_delivery;
85
85
  pub use leader_receiver::{
86
- claim_leader_receiver, mirror_peer_message_to_leader, send_to_leader_receiver,
86
+ claim_leader_receiver, deliver_to_leader_fallback_pane, mirror_peer_message_to_leader,
87
+ send_to_leader_receiver, send_to_leader_receiver_with_message_id,
87
88
  };
88
89
  pub use peers::allow_peer_talk;
89
90
  pub use results::{
90
91
  collect, collect_for_team, collect_results_and_notify_watchers, report_result,
91
- report_result_for_owner_team,
92
+ report_result_for_owner_team, report_result_for_owner_team_with_primary_error,
92
93
  };
93
94
  pub use scheduler::{detect_stuck_agents, fire_due_scheduled_events, stuck_cancel, stuck_list};
94
95
  pub use selftest::{evaluate_idle_behavior, run_comms_selftest, CommsSelftestDriver};
95
- pub use send::{apply_worker_sender_bypass, send_message, session_drift_refusal, MessageTarget, SendOptions};
96
+ pub use send::{
97
+ apply_worker_sender_bypass, send_message, session_drift_refusal, MessageTarget, SendOptions,
98
+ };
96
99
  pub use trust::{attempt_trust_auto_answer, TrustAnswerOutcome};
97
100
  pub use types::{
98
101
  ActivityStatus, AgentActivity, AlertSnapshot, AlertSuppression, AlertType, CheckEvidence,