@team-agent/installer 0.3.20 → 0.3.22

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 +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/runtime_detectors.rs +251 -2
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  11. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  13. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  14. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  15. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  16. package/crates/team-agent/src/lifecycle/restart/common.rs +76 -0
  17. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  18. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  19. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  20. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  21. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  22. package/crates/team-agent/src/messaging/send.rs +10 -0
  23. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  24. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  25. package/crates/team-agent/src/session_capture.rs +261 -1
  26. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  27. package/crates/team-agent/src/tmux_backend.rs +138 -2
  28. package/crates/team-agent/src/transport/test_support.rs +25 -0
  29. package/crates/team-agent/src/transport.rs +11 -0
  30. package/package.json +4 -4
@@ -10,8 +10,8 @@ use crate::message_store::MessageStore;
10
10
  use crate::model::enums::{PaneLiveness, Provider};
11
11
  use crate::model::ids::TeamKey;
12
12
  use crate::transport::{
13
- submit_verification_wire, InjectPayload, InjectReport, Key, PaneId, SessionName,
14
- SubmitVerification, Target, Transport, WindowName,
13
+ submit_verification_wire, InjectPayload, InjectReport, InjectVerification, Key, PaneId,
14
+ PaneInfo, SessionName, SubmitVerification, Target, Transport, WindowName,
15
15
  };
16
16
 
17
17
  use super::helpers::{message_exists, MessageStatusShadow};
@@ -84,11 +84,19 @@ pub fn tmux_pane_width(transport: &dyn Transport, target: &Target) -> PaneWidthQ
84
84
  match result {
85
85
  Ok(Some(raw)) => match raw.trim().parse::<u32>() {
86
86
  Ok(pane_width) if pane_width > 0 => PaneWidthQuery::Ok { pane_width },
87
- Ok(_) => PaneWidthQuery::Failed { error: "non_positive_width".to_string() },
88
- Err(_) => PaneWidthQuery::Failed { error: "unparseable_output".to_string() },
87
+ Ok(_) => PaneWidthQuery::Failed {
88
+ error: "non_positive_width".to_string(),
89
+ },
90
+ Err(_) => PaneWidthQuery::Failed {
91
+ error: "unparseable_output".to_string(),
92
+ },
93
+ },
94
+ Ok(None) => PaneWidthQuery::Failed {
95
+ error: "empty_output".to_string(),
96
+ },
97
+ Err(err) => PaneWidthQuery::Failed {
98
+ error: format!("tmux_query_failed:{err}"),
89
99
  },
90
- Ok(None) => PaneWidthQuery::Failed { error: "empty_output".to_string() },
91
- Err(err) => PaneWidthQuery::Failed { error: format!("tmux_query_failed:{err}") },
92
100
  }
93
101
  }
94
102
 
@@ -131,8 +139,18 @@ pub fn deliver_pending_message(
131
139
  let scoped_state;
132
140
  let state = match message.owner_team_id.as_deref() {
133
141
  Some(team) if !team.is_empty() => {
134
- match project_state_for_owner_team(workspace, team, state, Some(store), Some(message_id), Some(event_log))? {
135
- OwnerTeamProjection::Projected { state, canonical_team } => {
142
+ match project_state_for_owner_team(
143
+ workspace,
144
+ team,
145
+ state,
146
+ Some(store),
147
+ Some(message_id),
148
+ Some(event_log),
149
+ )? {
150
+ OwnerTeamProjection::Projected {
151
+ state,
152
+ canonical_team,
153
+ } => {
136
154
  canonical_owner_team_id = Some(canonical_team);
137
155
  scoped_state = state;
138
156
  &scoped_state
@@ -142,7 +160,11 @@ pub fn deliver_pending_message(
142
160
  }
143
161
  _ => state,
144
162
  };
145
- if !store.claim_for_delivery(message_id)? && message.status != "target_resolved" {
163
+ let attempt = if store.claim_for_delivery(message_id)? {
164
+ message.delivery_attempts.saturating_add(1)
165
+ } else if message.status == "target_resolved" {
166
+ bump_delivery_attempts(store, message_id)?
167
+ } else {
146
168
  return Ok(DeliveryOutcome {
147
169
  ok: false,
148
170
  status: DeliveryStatus::Refused,
@@ -153,7 +175,7 @@ pub fn deliver_pending_message(
153
175
  reason: Some(DeliveryRefusal::MessageAlreadyClaimed),
154
176
  channel: None,
155
177
  });
156
- }
178
+ };
157
179
  if message.recipient == "leader" && leader_receiver_has_noncanonical_tmux_socket(state) {
158
180
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
159
181
  event_log.write(
@@ -172,9 +194,7 @@ pub fn deliver_pending_message(
172
194
  status: DeliveryStatus::Refused,
173
195
  message_status: MessageStatusShadow("failed".to_string()),
174
196
  message_id: Some(message_id.to_string()),
175
- verification: Some(
176
- "run team-agent claim-leader or team-agent takeover".to_string(),
177
- ),
197
+ verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
178
198
  stage: None,
179
199
  reason: Some(DeliveryRefusal::LeaderNotAttached),
180
200
  channel: Some("rebind_required".to_string()),
@@ -201,15 +221,13 @@ pub fn deliver_pending_message(
201
221
  status: DeliveryStatus::Refused,
202
222
  message_status: MessageStatusShadow("failed".to_string()),
203
223
  message_id: Some(message_id.to_string()),
204
- verification: Some(
205
- "run team-agent claim-leader or team-agent takeover".to_string(),
206
- ),
224
+ verification: Some("run team-agent claim-leader or team-agent takeover".to_string()),
207
225
  stage: None,
208
226
  reason: Some(DeliveryRefusal::LeaderNotAttached),
209
227
  channel: Some("rebind_required".to_string()),
210
228
  });
211
229
  }
212
- let target = resolve_inject_target(state, &message.recipient);
230
+ let target = resolve_inject_target(state, &message.recipient, transport);
213
231
  // Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
214
232
  // menu is NOT provider delivery — the menu consumes the Enter and the task text
215
233
  // is lost (PROBE-2 root-cause). Before injection, peek at the recipient's pane for
@@ -244,48 +262,97 @@ pub fn deliver_pending_message(
244
262
  &message.content,
245
263
  message_id,
246
264
  );
247
- let inject_report = match transport.inject(
248
- &target,
249
- &InjectPayload::Text(rendered),
250
- Key::Enter,
251
- true,
252
- ) {
253
- Ok(report) => report,
254
- Err(error) => {
255
- if message.recipient == "leader" {
256
- store.mark(message_id, "failed", Some("leader_not_attached"))?;
265
+ let inject_report =
266
+ match transport.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true) {
267
+ Ok(report) => report,
268
+ Err(error) => {
269
+ let reason = format!("inject_failed:{error}");
270
+ if message.recipient == "leader" {
271
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
272
+ event_log.write(
273
+ "leader_receiver.delivery_blocked",
274
+ serde_json::json!({
275
+ "message_id": message_id,
276
+ "sender": message.sender,
277
+ "reason": "leader_not_attached",
278
+ "channel": "rebind_required",
279
+ "action": "run team-agent claim-leader or team-agent takeover",
280
+ "error": error.to_string(),
281
+ }),
282
+ )?;
283
+ return Ok(DeliveryOutcome {
284
+ ok: false,
285
+ status: DeliveryStatus::Refused,
286
+ message_status: MessageStatusShadow("failed".to_string()),
287
+ message_id: Some(message_id.to_string()),
288
+ verification: Some(
289
+ "run team-agent claim-leader or team-agent takeover".to_string(),
290
+ ),
291
+ stage: None,
292
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
293
+ channel: Some("rebind_required".to_string()),
294
+ });
295
+ }
257
296
  event_log.write(
258
- "leader_receiver.delivery_blocked",
297
+ "send.inject_failed",
259
298
  serde_json::json!({
260
299
  "message_id": message_id,
261
- "sender": message.sender,
262
- "reason": "leader_not_attached",
263
- "channel": "rebind_required",
264
- "action": "run team-agent claim-leader or team-agent takeover",
300
+ "recipient": message.recipient,
301
+ "attempts": attempt,
302
+ "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
265
303
  "error": error.to_string(),
266
304
  }),
267
305
  )?;
306
+ if attempt >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
307
+ store.mark(message_id, "failed", Some("send_inject_exhausted"))?;
308
+ emit_send_failed_exhausted(
309
+ workspace,
310
+ state,
311
+ event_log,
312
+ message_id,
313
+ &message.recipient,
314
+ attempt,
315
+ "send_inject_exhausted",
316
+ &reason,
317
+ )?;
318
+ return Ok(DeliveryOutcome {
319
+ ok: false,
320
+ status: DeliveryStatus::Failed,
321
+ message_status: MessageStatusShadow("failed".to_string()),
322
+ message_id: Some(message_id.to_string()),
323
+ verification: Some(reason),
324
+ stage: Some(DeliveryStage::Inject),
325
+ reason: None,
326
+ channel: None,
327
+ });
328
+ }
329
+ store.mark(message_id, "target_resolved", Some(&reason))?;
268
330
  return Ok(DeliveryOutcome {
269
331
  ok: false,
270
- status: DeliveryStatus::Refused,
271
- message_status: MessageStatusShadow("failed".to_string()),
332
+ status: DeliveryStatus::Degraded,
333
+ message_status: MessageStatusShadow("target_resolved".to_string()),
272
334
  message_id: Some(message_id.to_string()),
273
- verification: Some(
274
- "run team-agent claim-leader or team-agent takeover".to_string(),
275
- ),
276
- stage: None,
277
- reason: Some(DeliveryRefusal::LeaderNotAttached),
278
- channel: Some("rebind_required".to_string()),
335
+ verification: Some(reason),
336
+ stage: Some(DeliveryStage::Inject),
337
+ reason: None,
338
+ channel: None,
279
339
  });
280
340
  }
281
- return Err(error.into());
282
- }
283
- };
284
- if !inject_submit_verified(&inject_report) {
285
- let reason = format!(
286
- "submit_unverified:{}",
287
- submit_verification_wire(inject_report.submit_verification)
288
- );
341
+ };
342
+ let submit_verified = inject_submit_verified(&inject_report);
343
+ let readback_verified = pane_readback_verified(&inject_report);
344
+ // U1 #7: delivery is verified only when the submit succeeded AND the token was read
345
+ // back as visible in the pane. A submit-verified-but-token-missing inject (silent
346
+ // paste drop) is NOT delivered — it falls through to the unverified/degraded path.
347
+ if !(submit_verified && readback_verified) {
348
+ let reason = if !readback_verified {
349
+ "pane_readback_unverified:capture_missing_token".to_string()
350
+ } else {
351
+ format!(
352
+ "submit_unverified:{}",
353
+ submit_verification_wire(inject_report.submit_verification)
354
+ )
355
+ };
289
356
  event_log.write(
290
357
  "send.unverified",
291
358
  serde_json::json!({
@@ -304,6 +371,7 @@ pub fn deliver_pending_message(
304
371
  message_id,
305
372
  &message.recipient,
306
373
  inject_report.attempts,
374
+ "send_unverified_exhausted",
307
375
  &reason,
308
376
  )?;
309
377
  return Ok(DeliveryOutcome {
@@ -371,6 +439,20 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
371
439
  }
372
440
  }
373
441
 
442
+ /// U1 #7 step-2: pane-readback gate. The submit may report verified (a key was sent)
443
+ /// while the pasted token never actually landed in the pane — `inject()` step-1 now
444
+ /// surfaces that as `InjectVerification::CaptureMissingToken`. A delivery is only truly
445
+ /// verified when BOTH the submit succeeded AND the token was read back as visible.
446
+ /// `CaptureMissingToken` → readback failed → not delivered (degraded/unverified). All
447
+ /// other inject_verification variants (incl. token-less payloads, empty sends, new
448
+ /// pasted-content prompt) are not readback-negative and pass this gate.
449
+ fn pane_readback_verified(report: &InjectReport) -> bool {
450
+ !matches!(
451
+ report.inject_verification,
452
+ InjectVerification::CaptureMissingToken
453
+ )
454
+ }
455
+
374
456
  /// Render a message into the worker-facing protocol block (port of `rust_core.py:render_message`,
375
457
  /// golden-verified): `Team Agent message from {sender}[ for {task_id}]:\n\n{content}\n\n
376
458
  /// [team-agent-token:{message_id}]`. The worker (fake or real provider) only builds a result_envelope
@@ -391,20 +473,18 @@ fn render_message(sender: &str, task_id: Option<&str>, content: &str, message_id
391
473
  ///
392
474
  /// Leader delivery uses the bound leader receiver pane. The leader is not a worker agent and
393
475
  /// must not fall through to a synthetic `SessionWindow{window="leader"}` target.
394
- fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
476
+ fn resolve_inject_target(
477
+ state: &serde_json::Value,
478
+ recipient: &str,
479
+ transport: &dyn Transport,
480
+ ) -> Target {
481
+ let live_targets = transport.list_targets().unwrap_or_default();
395
482
  if recipient == "leader" {
396
483
  if let Some(pane_id) = leader_receiver_pane_id(state) {
397
484
  return Target::Pane(PaneId::new(pane_id));
398
485
  }
399
486
  }
400
487
  let agent = state.get("agents").and_then(|a| a.get(recipient));
401
- if let Some(pane_id) = agent
402
- .and_then(|a| a.get("pane_id"))
403
- .and_then(serde_json::Value::as_str)
404
- .filter(|s| !s.is_empty())
405
- {
406
- return Target::Pane(PaneId::new(pane_id));
407
- }
408
488
  let session = state
409
489
  .get("session_name")
410
490
  .and_then(serde_json::Value::as_str)
@@ -414,12 +494,57 @@ fn resolve_inject_target(state: &serde_json::Value, recipient: &str) -> Target {
414
494
  .and_then(serde_json::Value::as_str)
415
495
  .filter(|s| !s.is_empty())
416
496
  .unwrap_or(recipient);
497
+ let cached_pane = agent
498
+ .and_then(|a| a.get("pane_id"))
499
+ .and_then(serde_json::Value::as_str)
500
+ .filter(|s| !s.is_empty())
501
+ .map(PaneId::new);
502
+ if let Some(pane) = cached_pane.as_ref() {
503
+ if live_targets
504
+ .iter()
505
+ .any(|target| target.pane_id.as_str() == pane.as_str())
506
+ {
507
+ return Target::Pane(pane.clone());
508
+ }
509
+ }
510
+ if let Some(live_pane) = live_pane_for_session_window(&live_targets, session, window) {
511
+ return Target::Pane(live_pane);
512
+ }
513
+ if let Some(pane) = cached_pane {
514
+ if live_targets.is_empty() && !cached_pane_known_dead(transport, &pane) {
515
+ return Target::Pane(pane);
516
+ }
517
+ }
417
518
  Target::SessionWindow {
418
519
  session: SessionName::new(session),
419
520
  window: WindowName::new(window),
420
521
  }
421
522
  }
422
523
 
524
+ fn live_pane_for_session_window(
525
+ targets: &[PaneInfo],
526
+ session: &str,
527
+ window: &str,
528
+ ) -> Option<PaneId> {
529
+ targets
530
+ .iter()
531
+ .find(|target| {
532
+ target.session.as_str() == session
533
+ && target
534
+ .window_name
535
+ .as_ref()
536
+ .is_some_and(|name| name.as_str() == window)
537
+ })
538
+ .map(|target| target.pane_id.clone())
539
+ }
540
+
541
+ fn cached_pane_known_dead(transport: &dyn Transport, pane: &PaneId) -> bool {
542
+ if matches!(transport.has_pane(pane), Ok(Some(false))) {
543
+ return true;
544
+ }
545
+ matches!(transport.liveness(pane), Ok(PaneLiveness::Dead))
546
+ }
547
+
423
548
  /// Read the bound leader pane id off the projected or team-scoped runtime state.
424
549
  fn leader_receiver_pane_id(state: &serde_json::Value) -> Option<&str> {
425
550
  leader_receiver_pane_id_in_state(state)
@@ -439,7 +564,10 @@ fn leader_receiver_pane_is_usable(transport: &dyn Transport, state: &serde_json:
439
564
  {
440
565
  return true;
441
566
  }
442
- !matches!(transport.liveness(&PaneId::new(pane_id)), Ok(PaneLiveness::Dead))
567
+ !matches!(
568
+ transport.liveness(&PaneId::new(pane_id)),
569
+ Ok(PaneLiveness::Dead)
570
+ )
443
571
  }
444
572
 
445
573
  enum DeliveryTransport<'a> {
@@ -523,13 +651,15 @@ fn delivery_transport_for_recipient<'a>(
523
651
  }
524
652
 
525
653
  fn leader_receiver_pane_id_in_state(state: &serde_json::Value) -> Option<&str> {
526
- ["leader_receiver", "team_owner"].into_iter().find_map(|key| {
527
- state
528
- .get(key)
529
- .and_then(|r| r.get("pane_id"))
530
- .and_then(serde_json::Value::as_str)
531
- .filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")
532
- })
654
+ ["leader_receiver", "team_owner"]
655
+ .into_iter()
656
+ .find_map(|key| {
657
+ state
658
+ .get(key)
659
+ .and_then(|r| r.get("pane_id"))
660
+ .and_then(serde_json::Value::as_str)
661
+ .filter(|s| !s.is_empty() && *s != "__team_agent_unbound__")
662
+ })
533
663
  }
534
664
 
535
665
  fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
@@ -538,15 +668,17 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
538
668
 
539
669
  fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
540
670
  leader_receiver_tmux_socket(state)
541
- .is_some_and(|socket| {
542
- socket != "default" && !std::path::Path::new(socket).is_absolute()
543
- })
671
+ .is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
544
672
  }
545
673
 
546
674
  fn leader_receiver_field<'a>(state: &'a serde_json::Value, field: &str) -> Option<&'a str> {
547
675
  leader_receiver_field_in_state(state, field)
548
- .or_else(|| active_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
549
- .or_else(|| only_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field)))
676
+ .or_else(|| {
677
+ active_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field))
678
+ })
679
+ .or_else(|| {
680
+ only_team_entry(state).and_then(|team| leader_receiver_field_in_state(team, field))
681
+ })
550
682
  }
551
683
 
552
684
  fn leader_receiver_field_in_state<'a>(
@@ -567,6 +699,7 @@ fn emit_send_failed_exhausted(
567
699
  message_id: &str,
568
700
  recipient: &str,
569
701
  attempts: u32,
702
+ failure_reason: &str,
570
703
  verification: &str,
571
704
  ) -> Result<(), MessagingError> {
572
705
  event_log.write(
@@ -576,12 +709,12 @@ fn emit_send_failed_exhausted(
576
709
  "recipient": recipient,
577
710
  "attempts": attempts,
578
711
  "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
579
- "reason": "send_unverified_exhausted",
712
+ "reason": failure_reason,
580
713
  "verification": verification,
581
714
  }),
582
715
  )?;
583
716
  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"
717
+ "send.failed\nerror: send to {recipient} failed with {failure_reason} after {attempts}/{SEND_RETRY_MAX_ATTEMPTS} attempts\naction: inspect the target pane and retry the send\nlog: .team/logs/events.jsonl"
585
718
  );
586
719
  match crate::messaging::send_to_leader_receiver(
587
720
  workspace,
@@ -620,7 +753,9 @@ fn emit_send_failed_exhausted(
620
753
  }
621
754
 
622
755
  fn active_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
623
- let team = state.get("active_team_key").and_then(serde_json::Value::as_str)?;
756
+ let team = state
757
+ .get("active_team_key")
758
+ .and_then(serde_json::Value::as_str)?;
624
759
  state
625
760
  .get("teams")
626
761
  .and_then(serde_json::Value::as_object)
@@ -661,12 +796,31 @@ pub fn deliver_pending_messages(
661
796
  let scoped_state;
662
797
  let state = match message.owner_team_id.as_deref() {
663
798
  Some(team) if !team.is_empty() => {
664
- match project_state_for_owner_team(workspace, team, state, Some(&store), Some(&message_id), Some(event_log))? {
799
+ match project_state_for_owner_team(
800
+ workspace,
801
+ team,
802
+ state,
803
+ Some(&store),
804
+ Some(&message_id),
805
+ Some(event_log),
806
+ )? {
665
807
  OwnerTeamProjection::Projected { state, .. } => {
666
808
  scoped_state = state;
667
809
  &scoped_state
668
810
  }
669
- OwnerTeamProjection::Refused(_) => continue,
811
+ OwnerTeamProjection::Refused(outcome) => {
812
+ let _ = event_log.write(
813
+ "delivery.projection_refused",
814
+ serde_json::json!({
815
+ "message_id": message_id.as_str(),
816
+ "owner_team_id": team,
817
+ "status": super::helpers::status_wire(outcome.status),
818
+ "verification": outcome.verification,
819
+ "reason": format!("{:?}", outcome.reason),
820
+ }),
821
+ );
822
+ continue;
823
+ }
670
824
  }
671
825
  }
672
826
  _ => state,
@@ -684,7 +838,26 @@ pub fn deliver_pending_messages(
684
838
  continue;
685
839
  }
686
840
  }
687
- let outcome = deliver_pending_message(workspace, &store, transport, &message_id, event_log, state)?;
841
+ let outcome = match deliver_pending_message(
842
+ workspace,
843
+ &store,
844
+ transport,
845
+ &message_id,
846
+ event_log,
847
+ state,
848
+ ) {
849
+ Ok(outcome) => outcome,
850
+ Err(error) => {
851
+ let _ = event_log.write(
852
+ "delivery.item_blocked",
853
+ serde_json::json!({
854
+ "message_id": message_id.as_str(),
855
+ "error": error.to_string(),
856
+ }),
857
+ );
858
+ continue;
859
+ }
860
+ };
688
861
  if outcome.ok {
689
862
  delivered.push(message_id);
690
863
  }
@@ -699,6 +872,7 @@ struct PendingMessage {
699
872
  task_id: Option<String>,
700
873
  owner_team_id: Option<String>,
701
874
  status: String,
875
+ delivery_attempts: u32,
702
876
  }
703
877
 
704
878
  fn message_for_delivery(
@@ -708,7 +882,7 @@ fn message_for_delivery(
708
882
  let conn = crate::db::schema::open_db(store.db_path())?;
709
883
  let message = conn
710
884
  .query_row(
711
- "select sender, recipient, content, task_id, owner_team_id, status from messages where message_id = ?1",
885
+ "select sender, recipient, content, task_id, owner_team_id, status, delivery_attempts from messages where message_id = ?1",
712
886
  params![message_id],
713
887
  |row| {
714
888
  Ok(PendingMessage {
@@ -718,6 +892,7 @@ fn message_for_delivery(
718
892
  task_id: row.get::<_, Option<String>>(3)?,
719
893
  owner_team_id: row.get::<_, Option<String>>(4)?,
720
894
  status: row.get::<_, String>(5)?,
895
+ delivery_attempts: row.get::<_, i64>(6)?.max(0) as u32,
721
896
  })
722
897
  },
723
898
  )
@@ -725,6 +900,23 @@ fn message_for_delivery(
725
900
  Ok(message)
726
901
  }
727
902
 
903
+ fn bump_delivery_attempts(store: &MessageStore, message_id: &str) -> Result<u32, MessagingError> {
904
+ let conn = crate::db::schema::open_db(store.db_path())?;
905
+ conn.execute(
906
+ "update messages
907
+ set delivery_attempts = delivery_attempts + 1,
908
+ updated_at = ?2
909
+ where message_id = ?1",
910
+ params![message_id, chrono::Utc::now().to_rfc3339()],
911
+ )?;
912
+ let attempts = conn.query_row(
913
+ "select delivery_attempts from messages where message_id = ?1",
914
+ params![message_id],
915
+ |row| row.get::<_, i64>(0),
916
+ )?;
917
+ Ok(attempts.max(0) as u32)
918
+ }
919
+
728
920
  /// Pre-inject gate (Contract B): peek the recipient pane and answer "is there an
729
921
  /// actionable provider startup prompt right now (trust menu or update prompt)" using
730
922
  /// the SHARED provider/startup_prompt recognizers — no second classifier, no provider
@@ -744,7 +936,10 @@ fn recipient_pane_has_actionable_startup_prompt(
744
936
  let provider = agent
745
937
  .and_then(|agent| agent.get("provider"))
746
938
  .and_then(serde_json::Value::as_str);
747
- if !matches!(provider, Some("codex" | "claude" | "claude_code" | "copilot")) {
939
+ if !matches!(
940
+ provider,
941
+ Some("codex" | "claude" | "claude_code" | "copilot")
942
+ ) {
748
943
  return false;
749
944
  }
750
945
  // step2-retry/scrollback root-cause (rt binary 6c9c6c1c): once the agent's
@@ -803,7 +998,11 @@ pub fn handle_trust_retry_needed(
803
998
  event_log: &EventLog,
804
999
  ) -> Result<DeliveryOutcome, MessagingError> {
805
1000
  if payload.attempt >= payload.max_attempts {
806
- let _ = store.mark(&payload.message_id, "failed", Some("trust_auto_answer_exhausted"));
1001
+ let _ = store.mark(
1002
+ &payload.message_id,
1003
+ "failed",
1004
+ Some("trust_auto_answer_exhausted"),
1005
+ );
807
1006
  event_log.write(
808
1007
  "leader_panes.trust_auto_answer_exhausted",
809
1008
  serde_json::json!({"message_id": payload.message_id, "attempt": payload.attempt}),
@@ -872,7 +1071,14 @@ pub fn execute_trust_retry(
872
1071
  let _ = owner_team_id;
873
1072
  let _ = store.mark(&payload.message_id, "accepted", None);
874
1073
  let state = crate::state::persist::load_runtime_state(workspace)?;
875
- deliver_pending_message(workspace, store, transport, &payload.message_id, event_log, &state)
1074
+ deliver_pending_message(
1075
+ workspace,
1076
+ store,
1077
+ transport,
1078
+ &payload.message_id,
1079
+ event_log,
1080
+ &state,
1081
+ )
876
1082
  }
877
1083
 
878
1084
  /// `_record_turn_open_if_leader_to_worker` (`delivery.py:430`):**take-over arm 来自真实投递**
@@ -887,12 +1093,7 @@ pub fn record_turn_open_if_leader_to_worker(
887
1093
  ) -> Result<(), MessagingError> {
888
1094
  let _ = state;
889
1095
  record_turn_open_if_leader_to_worker_scoped(
890
- workspace,
891
- sender,
892
- recipient,
893
- delivered,
894
- event_log,
895
- None,
1096
+ workspace, sender, recipient, delivered, event_log, None,
896
1097
  )
897
1098
  }
898
1099
 
@@ -957,7 +1158,11 @@ fn stamp_first_send_at_if_leader_to_worker_scoped(
957
1158
  .and_then(|agents| agents.get_mut(recipient))
958
1159
  .and_then(serde_json::Value::as_object_mut)
959
1160
  {
960
- if !agent.contains_key("first_send_at") || agent.get("first_send_at").is_some_and(serde_json::Value::is_null) {
1161
+ if !agent.contains_key("first_send_at")
1162
+ || agent
1163
+ .get("first_send_at")
1164
+ .is_some_and(serde_json::Value::is_null)
1165
+ {
961
1166
  agent.insert("first_send_at".to_string(), serde_json::Value::String(now));
962
1167
  save_scoped_state(workspace, &state, owner_team_id)?;
963
1168
  }
@@ -992,7 +1197,11 @@ fn save_scoped_state(
992
1197
  .and_then(serde_json::Value::as_object)
993
1198
  .is_some_and(|teams| {
994
1199
  owner_team_id
995
- .and_then(|team| crate::state::projection::resolve_owner_team_id(state, team).canonical_key().map(str::to_string))
1200
+ .and_then(|team| {
1201
+ crate::state::projection::resolve_owner_team_id(state, team)
1202
+ .canonical_key()
1203
+ .map(str::to_string)
1204
+ })
996
1205
  .is_some_and(|team| teams.contains_key(&team))
997
1206
  })
998
1207
  {
@@ -1007,7 +1216,10 @@ fn save_scoped_state(
1007
1216
  }
1008
1217
 
1009
1218
  enum OwnerTeamProjection {
1010
- Projected { state: serde_json::Value, canonical_team: String },
1219
+ Projected {
1220
+ state: serde_json::Value,
1221
+ canonical_team: String,
1222
+ },
1011
1223
  Refused(DeliveryOutcome),
1012
1224
  }
1013
1225
 
@@ -1025,9 +1237,15 @@ fn project_state_for_owner_team(
1025
1237
  .and_then(serde_json::Value::as_object)
1026
1238
  .is_some_and(|teams| !teams.is_empty());
1027
1239
  let (mut projection_source, mut resolution) = if fallback_has_teams {
1028
- (fallback, crate::state::projection::resolve_owner_team_id(fallback, team))
1240
+ (
1241
+ fallback,
1242
+ crate::state::projection::resolve_owner_team_id(fallback, team),
1243
+ )
1029
1244
  } else {
1030
- (&raw, crate::state::projection::resolve_owner_team_id(&raw, team))
1245
+ (
1246
+ &raw,
1247
+ crate::state::projection::resolve_owner_team_id(&raw, team),
1248
+ )
1031
1249
  };
1032
1250
  if !fallback_has_teams && matches!(resolution, OwnerTeamResolution::Unresolved { .. }) {
1033
1251
  let fallback_resolution = crate::state::projection::resolve_owner_team_id(fallback, team);
@@ -1038,7 +1256,10 @@ fn project_state_for_owner_team(
1038
1256
  }
1039
1257
  let canonical_team = match resolution {
1040
1258
  OwnerTeamResolution::Canonical(canonical) => canonical,
1041
- OwnerTeamResolution::LegacyAlias { requested, canonical } => {
1259
+ OwnerTeamResolution::LegacyAlias {
1260
+ requested,
1261
+ canonical,
1262
+ } => {
1042
1263
  normalize_owner_team_id_rows(workspace, &requested, &canonical, message_id, event_log)?;
1043
1264
  canonical
1044
1265
  }
@@ -1088,10 +1309,15 @@ fn project_state_for_owner_team(
1088
1309
  });
1089
1310
  }
1090
1311
  let mut state = project_state_for_owner_team_value(projection_source, &canonical_team)
1091
- .ok_or_else(|| MessagingError::Routing(format!("owner_team_unresolved: {canonical_team}")))?;
1312
+ .ok_or_else(|| {
1313
+ MessagingError::Routing(format!("owner_team_unresolved: {canonical_team}"))
1314
+ })?;
1092
1315
  carry_top_level_leader_binding(&mut state, projection_source);
1093
1316
  carry_top_level_leader_binding(&mut state, &raw);
1094
- Ok(OwnerTeamProjection::Projected { state, canonical_team })
1317
+ Ok(OwnerTeamProjection::Projected {
1318
+ state,
1319
+ canonical_team,
1320
+ })
1095
1321
  }
1096
1322
 
1097
1323
  fn carry_top_level_leader_binding(projected: &mut serde_json::Value, raw: &serde_json::Value) {
@@ -1135,7 +1361,8 @@ pub(crate) fn normalize_owner_team_id_rows(
1135
1361
  "result_watchers",
1136
1362
  "leader_notification_log",
1137
1363
  ] {
1138
- let sql = format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
1364
+ let sql =
1365
+ format!("update or ignore {table} set owner_team_id = ?1 where owner_team_id = ?2");
1139
1366
  conn.execute(&sql, params![canonical, requested])?;
1140
1367
  }
1141
1368
  if let Some(event_log) = event_log {
@@ -1225,7 +1452,9 @@ pub fn retry_injection_after_trust_auto_answer(
1225
1452
  provider: Provider,
1226
1453
  event_log: &EventLog,
1227
1454
  ) -> Result<DeliveryOutcome, MessagingError> {
1228
- let _ = (workspace, state, transport, target, text, provider, event_log);
1455
+ let _ = (
1456
+ workspace, state, transport, target, text, provider, event_log,
1457
+ );
1229
1458
  Ok(DeliveryOutcome {
1230
1459
  ok: false,
1231
1460
  status: DeliveryStatus::RetryScheduled,