@team-agent/installer 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.4"
578
+ version = "0.5.5"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.4"
12
+ version = "0.5.5"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -1468,7 +1468,15 @@ fn format_inbox_human(
1468
1468
  for message in messages {
1469
1469
  let sender = message.get("sender").and_then(Value::as_str).unwrap_or("-");
1470
1470
  let content = message.get("content").and_then(Value::as_str).unwrap_or("");
1471
- lines.push(format!("- {sender}: {content}"));
1471
+ let status = message.get("status").and_then(Value::as_str).unwrap_or("-");
1472
+ let attempts = message
1473
+ .get("delivery_attempts")
1474
+ .and_then(Value::as_i64)
1475
+ .unwrap_or(0);
1476
+ let error = message.get("error").and_then(Value::as_str).unwrap_or("-");
1477
+ lines.push(format!(
1478
+ "- {sender}: {content} [status={status} attempts={attempts} error={error}]"
1479
+ ));
1472
1480
  }
1473
1481
  }
1474
1482
  let pending = uncollected_result_count(workspace, owner_team_id)?;
@@ -90,9 +90,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
90
90
  {
91
91
  return Ok(CmdResult::from_json(amb, args.json));
92
92
  }
93
- let outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
94
- let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
93
+ let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
95
94
  if opts.watch_result {
95
+ outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
96
+ }
97
+ let mut value = delivery_outcome_json(&outcome, &target, &content, &opts);
98
+ if opts.watch_result && initial_delivery_allows_watch(outcome.status) {
96
99
  if let Some(obj) = value.as_object_mut() {
97
100
  obj.insert("watch".to_string(), watch_notice_json(&target, &opts));
98
101
  }
@@ -575,6 +578,48 @@ fn primary_delivery_succeeded(status: DeliveryStatus) -> bool {
575
578
  )
576
579
  }
577
580
 
581
+ fn initial_delivery_allows_watch(status: DeliveryStatus) -> bool {
582
+ matches!(
583
+ status,
584
+ DeliveryStatus::Delivered | DeliveryStatus::AlreadyDelivered
585
+ )
586
+ }
587
+
588
+ fn observe_initial_delivery_for_watch(
589
+ selected: &crate::state::selector::SelectedTeam,
590
+ target: &MessageTarget,
591
+ outcome: &DeliveryOutcome,
592
+ opts: &SendOptions,
593
+ ) -> Result<DeliveryOutcome, CliError> {
594
+ if !matches!(target, MessageTarget::Single(agent) if !agent.is_empty()) {
595
+ return Ok(outcome.clone());
596
+ }
597
+ if !matches!(outcome.status, DeliveryStatus::Queued) {
598
+ return Ok(outcome.clone());
599
+ }
600
+ let Some(message_id) = outcome.message_id.as_deref() else {
601
+ return Ok(outcome.clone());
602
+ };
603
+ let store = crate::message_store::MessageStore::open(&selected.run_workspace)
604
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
605
+ let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
606
+ &selected.run_workspace,
607
+ opts.team.as_ref().map(TeamKey::as_str),
608
+ )
609
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
610
+ let event_log = crate::event_log::EventLog::new(&selected.run_workspace);
611
+ let state = selected_state_with_active_key(selected);
612
+ crate::messaging::delivery::deliver_pending_message(
613
+ &selected.run_workspace,
614
+ &store,
615
+ &transport,
616
+ message_id,
617
+ &event_log,
618
+ &state,
619
+ )
620
+ .map_err(CliError::from)
621
+ }
622
+
578
623
  fn is_business_reject_text(error: &str) -> bool {
579
624
  let lower = error.to_ascii_lowercase();
580
625
  [
@@ -668,6 +713,8 @@ fn delivery_outcome_json(
668
713
  json!({
669
714
  "ok": outcome.ok,
670
715
  "status": delivery_status_wire(outcome.status),
716
+ "delivery_status": api_delivery_status(outcome),
717
+ "delivered": delivery_proven(outcome.status),
671
718
  "target": target_wire,
672
719
  "agent_id": first_target(target),
673
720
  "content_length_bytes": content.len(),
@@ -681,6 +728,26 @@ fn delivery_outcome_json(
681
728
  })
682
729
  }
683
730
 
731
+ fn api_delivery_status(outcome: &DeliveryOutcome) -> &'static str {
732
+ if delivery_proven(outcome.status) {
733
+ return "delivered";
734
+ }
735
+ if matches!(outcome.status, DeliveryStatus::Queued) && outcome.message_status.0 == "accepted" {
736
+ return "pending";
737
+ }
738
+ delivery_status_wire(outcome.status)
739
+ }
740
+
741
+ fn delivery_proven(status: DeliveryStatus) -> bool {
742
+ matches!(
743
+ status,
744
+ DeliveryStatus::Delivered
745
+ | DeliveryStatus::AlreadyDelivered
746
+ | DeliveryStatus::BroadcastDelivered
747
+ | DeliveryStatus::FanoutDelivered
748
+ )
749
+ }
750
+
684
751
  fn add_send_reminder_if_ok(value: &mut Value) {
685
752
  if value.get("ok").and_then(Value::as_bool) != Some(true) {
686
753
  return;
@@ -99,6 +99,7 @@ use rusqlite::params;
99
99
  "tasks": tasks,
100
100
  "messages": message_counts(&conn, owner_team_id)?,
101
101
  "queued_messages": queued_messages(&conn, owner_team_id, 8)?,
102
+ "pending_leader_notifications": pending_leader_notifications(&conn, owner_team_id, 8)?,
102
103
  "results": result_counts(&conn, owner_team_id)?,
103
104
  "latest_results": latest_result_summaries(&store, owner_team_id)?,
104
105
  "readiness": readiness,
@@ -639,6 +640,67 @@ use rusqlite::params;
639
640
  Ok(Value::Array(values))
640
641
  }
641
642
 
643
+ /// 0.5.5 gate054 round-2: leader notifications that were refused with
644
+ /// `rebind_required` (status=failed, error=leader_not_attached) sit as
645
+ /// failed rows in the store; without a dedicated status field the
646
+ /// operator sees only `messages.failed=N` and cannot tell that the
647
+ /// notifications are waiting for a rebind. This field surfaces them
648
+ /// alongside `queued_messages` so `attach-leader` / `takeover` is
649
+ /// visibly the fix. Once the pane is rebound the requeue path flips
650
+ /// each row back to `status=accepted` and it drops out of this list.
651
+ fn pending_leader_notifications(
652
+ conn: &rusqlite::Connection,
653
+ owner_team_id: Option<&str>,
654
+ limit: usize,
655
+ ) -> Result<Value, CliError> {
656
+ let limit = i64::try_from(limit).unwrap_or(i64::MAX);
657
+ let sql = match owner_team_id {
658
+ Some(_) => {
659
+ "select message_id, sender, status, error, created_at, delivery_attempts
660
+ from messages
661
+ where recipient = 'leader'
662
+ and status = 'failed'
663
+ and error = 'leader_not_attached'
664
+ and owner_team_id = ?1
665
+ order by created_at desc
666
+ limit ?2"
667
+ }
668
+ None => {
669
+ "select message_id, sender, status, error, created_at, delivery_attempts
670
+ from messages
671
+ where recipient = 'leader'
672
+ and status = 'failed'
673
+ and error = 'leader_not_attached'
674
+ order by created_at desc
675
+ limit ?1"
676
+ }
677
+ };
678
+ let mut stmt = conn
679
+ .prepare(sql)
680
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
681
+ let map_row = |row: &rusqlite::Row<'_>| {
682
+ Ok(json!({
683
+ "message_id": row.get::<_, String>(0)?,
684
+ "sender": row.get::<_, Option<String>>(1)?,
685
+ "status": row.get::<_, String>(2)?,
686
+ "error": row.get::<_, Option<String>>(3)?,
687
+ "created_at": row.get::<_, Option<String>>(4)?,
688
+ "delivery_attempts": row.get::<_, i64>(5)?,
689
+ "channel": "rebind_required",
690
+ "action": "run team-agent attach-leader or team-agent takeover",
691
+ }))
692
+ };
693
+ let rows = match owner_team_id {
694
+ Some(team) => stmt.query_map(params![team, limit], map_row),
695
+ None => stmt.query_map(params![limit], map_row),
696
+ }
697
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
698
+ let values = rows
699
+ .collect::<Result<Vec<_>, _>>()
700
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
701
+ Ok(Value::Array(values))
702
+ }
703
+
642
704
  /// 0.4.x: slim default compact payload — exactly 7 top-level fields.
643
705
  /// Diagnostic detail moves to `--detail`. Plan:
644
706
  /// /Users/alauda/Documents/code/team-agent-public/.team/artifacts/status-compact-plan.md
@@ -511,33 +511,26 @@ use super::*;
511
511
  }
512
512
 
513
513
  #[test]
514
- fn cmd_send_watch_result_surfaces_registered_notice() {
515
- // gate CRITICAL: --watch-result -> SendOptions.watch_result=true -> send_message attaches
516
- // result['watch']={status:'registered', watcher_id, task_id, agent_id, notice}
517
- // (send.py:322-337). That dict MUST survive verbatim into CmdResult Json output.
514
+ fn cmd_send_watch_result_does_not_register_before_delivery() {
515
+ // 0.5.x send contract: --watch-result may only advertise a watcher after
516
+ // initial worker delivery is physically proven.
518
517
  let r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
519
518
  let v = match r.output {
520
519
  CmdOutput::Json(v) => v,
521
520
  other => panic!("expected Json, got {other:?}"),
522
521
  };
523
- let watch = v
524
- .get("watch")
525
- .expect("watch_result:true MUST attach result['watch'] (send.py:326)");
526
- assert_eq!(
527
- watch.get("status").and_then(|s| s.as_str()),
528
- Some("registered"),
529
- "registered-watcher notice status must be exactly 'registered'"
522
+ assert!(
523
+ v.get("delivery_status").and_then(|s| s.as_str()).is_some(),
524
+ "send output must expose delivery_status; got {v}"
530
525
  );
531
- assert!(watch.get("watcher_id").is_some(), "watch notice carries watcher_id");
532
526
  assert_eq!(
533
- watch.get("agent_id").and_then(|s| s.as_str()),
534
- Some("alice"),
535
- "watch notice agent_id == the send target"
527
+ v.get("delivered").and_then(|s| s.as_bool()),
528
+ Some(false),
529
+ "undelivered send outcome must not look delivered"
536
530
  );
537
- // non-queued notice golden bytes (send.py:335):
538
- assert_eq!(
539
- watch.get("notice").and_then(|s| s.as_str()),
540
- Some("Team Agent will collect the result and notify the leader when this task reports completion.")
531
+ assert!(
532
+ !v.as_object().unwrap().contains_key("watch"),
533
+ "watch_result:true must not attach result['watch'] before delivery; got {v}"
541
534
  );
542
535
  }
543
536
 
@@ -143,6 +143,7 @@ pub(crate) fn start_agent_at_paths(
143
143
  if let Ok(spec) = load_team_spec(spec_workspace) {
144
144
  write_team_state(spec_workspace, &spec, &state)?;
145
145
  }
146
+ replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
146
147
  let coordinator_started = start_coordinator_for_workspace(workspace)?;
147
148
  let target = format!("{}:{window}", session_name.as_str());
148
149
  write_start_agent_noop_event(workspace, agent_id, &target, coordinator_started)?;
@@ -282,6 +283,7 @@ pub(crate) fn start_agent_at_paths(
282
283
  spawn_session_id,
283
284
  tmux_start_mode_for_spawn(&spawn, into_existing_session),
284
285
  )?;
286
+ replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
285
287
  let coordinator_started = start_coordinator_for_workspace(workspace)?;
286
288
  Ok(StartAgentOutcome::Running {
287
289
  env: AgentActionEnvelope {
@@ -297,6 +299,33 @@ pub(crate) fn start_agent_at_paths(
297
299
  })
298
300
  }
299
301
 
302
+ fn replay_worker_target_missing_messages(
303
+ workspace: &Path,
304
+ agent_id: &AgentId,
305
+ team_key: &str,
306
+ state: &serde_json::Value,
307
+ transport: &dyn crate::transport::Transport,
308
+ ) -> Result<(), LifecycleError> {
309
+ let store = crate::message_store::MessageStore::open(workspace)
310
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
311
+ let event_log = crate::event_log::EventLog::new(workspace);
312
+ let ids = crate::messaging::delivery::requeue_worker_target_missing_messages(
313
+ workspace,
314
+ &store,
315
+ &event_log,
316
+ agent_id.as_str(),
317
+ Some(team_key),
318
+ )
319
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
320
+ if !ids.is_empty() {
321
+ crate::messaging::delivery::deliver_pending_messages(
322
+ workspace, state, transport, &event_log,
323
+ )
324
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
325
+ }
326
+ Ok(())
327
+ }
328
+
300
329
  fn verify_spawned_pane_matches_target(
301
330
  transport: &dyn crate::transport::Transport,
302
331
  pane: &crate::transport::PaneId,
@@ -165,7 +165,7 @@ pub fn deliver_pending_message(
165
165
  };
166
166
  let attempt = if store.claim_for_delivery(message_id)? {
167
167
  message.delivery_attempts.saturating_add(1)
168
- } else if message.status == "target_resolved" {
168
+ } else if message.status == "target_resolved" && message.error.is_some() {
169
169
  bump_delivery_attempts(store, message_id)?
170
170
  } else {
171
171
  return Ok(DeliveryOutcome {
@@ -190,6 +190,42 @@ pub fn deliver_pending_message(
190
190
  )? {
191
191
  return Ok(outcome);
192
192
  }
193
+ // 0.5.4 gate054 cross-team leak fix: a `leader_receiver.status`
194
+ // other than `"attached"` (restart demotes killed-session receivers
195
+ // to `"rebind_required"` — lifecycle/restart/rebuild.rs:1311) MUST
196
+ // refuse delivery before any pane injection or cross-server pane-id
197
+ // probing. Without this gate, a stale receiver on team A's socket
198
+ // could otherwise fall back to the same pane id on the main tmux
199
+ // server (delivery.rs:1150-1201) and inject A's leader traffic into
200
+ // team B's leader pane. See .team/artifacts/0.5.4-gate-crossteam-notify-triage.md.
201
+ if let Some(status) = leader_receiver_status(state) {
202
+ if status != "attached" {
203
+ store.mark(message_id, "failed", Some("leader_not_attached"))?;
204
+ event_log.write(
205
+ "leader_receiver.delivery_blocked",
206
+ serde_json::json!({
207
+ "message_id": message_id,
208
+ "sender": message.sender,
209
+ "reason": "leader_receiver_not_attached",
210
+ "channel": "rebind_required",
211
+ "action": "run team-agent attach-leader or team-agent takeover",
212
+ "status": status,
213
+ }),
214
+ )?;
215
+ return Ok(DeliveryOutcome {
216
+ ok: false,
217
+ status: DeliveryStatus::Refused,
218
+ message_status: MessageStatusShadow("failed".to_string()),
219
+ message_id: Some(message_id.to_string()),
220
+ verification: Some(
221
+ "run team-agent attach-leader or team-agent takeover".to_string(),
222
+ ),
223
+ stage: None,
224
+ reason: Some(DeliveryRefusal::LeaderNotAttached),
225
+ channel: Some("rebind_required".to_string()),
226
+ });
227
+ }
228
+ }
193
229
  if crate::codex_app_server::receiver_is_app_server(receiver) {
194
230
  return deliver_leader_via_app_server(
195
231
  store,
@@ -288,6 +324,16 @@ pub fn deliver_pending_message(
288
324
  });
289
325
  }
290
326
  let target = resolve_inject_target(state, &message.recipient, transport, &live_targets);
327
+ if let Some(outcome) = block_missing_worker_target(
328
+ store,
329
+ event_log,
330
+ message_id,
331
+ &message.recipient,
332
+ &target,
333
+ &live_targets,
334
+ )? {
335
+ return Ok(outcome);
336
+ }
291
337
  // Contract B / MUST-10 / N31/N32: physical paste+Enter into a startup trust/update
292
338
  // menu is NOT provider delivery — the menu consumes the Enter and the task text
293
339
  // is lost (PROBE-2 root-cause). Before injection, peek at the recipient's pane for
@@ -358,6 +404,15 @@ pub fn deliver_pending_message(
358
404
  channel: Some("rebind_required".to_string()),
359
405
  });
360
406
  }
407
+ if transport_error_is_target_missing(&error) {
408
+ return mark_worker_target_missing(
409
+ store,
410
+ event_log,
411
+ message_id,
412
+ &message.recipient,
413
+ Some(reason),
414
+ );
415
+ }
361
416
  event_log.write(
362
417
  "send.inject_failed",
363
418
  serde_json::json!({
@@ -574,6 +629,136 @@ pub fn deliver_pending_message(
574
629
  Ok(outcome)
575
630
  }
576
631
 
632
+ fn block_missing_worker_target(
633
+ store: &MessageStore,
634
+ event_log: &EventLog,
635
+ message_id: &str,
636
+ recipient: &str,
637
+ target: &Target,
638
+ live_targets: &[PaneInfo],
639
+ ) -> Result<Option<DeliveryOutcome>, MessagingError> {
640
+ let Target::SessionWindow { session, window } = target else {
641
+ return Ok(None);
642
+ };
643
+ if live_targets.is_empty() {
644
+ return Ok(None);
645
+ }
646
+ if window.as_str().ends_with("_pane_conflicts_with_leader") {
647
+ return Ok(None);
648
+ }
649
+ if live_targets.iter().any(|pane| {
650
+ pane.session.as_str() == session.as_str()
651
+ && pane
652
+ .window_name
653
+ .as_ref()
654
+ .is_some_and(|name| name.as_str() == window.as_str())
655
+ }) {
656
+ return Ok(None);
657
+ }
658
+ mark_worker_target_missing(store, event_log, message_id, recipient, None).map(Some)
659
+ }
660
+
661
+ fn mark_worker_target_missing(
662
+ store: &MessageStore,
663
+ event_log: &EventLog,
664
+ message_id: &str,
665
+ recipient: &str,
666
+ verification: Option<String>,
667
+ ) -> Result<DeliveryOutcome, MessagingError> {
668
+ let action = format!("run team-agent start-agent {recipient} --allow-fresh");
669
+ store.mark(
670
+ message_id,
671
+ "queued_pane_missing",
672
+ Some("tmux_target_missing"),
673
+ )?;
674
+ event_log.write(
675
+ "send.inject_blocked",
676
+ serde_json::json!({
677
+ "message_id": message_id,
678
+ "recipient": recipient,
679
+ "reason": "tmux_target_missing",
680
+ "channel": "delivery_blocked",
681
+ "action": action,
682
+ "verification": verification,
683
+ }),
684
+ )?;
685
+ Ok(DeliveryOutcome {
686
+ ok: false,
687
+ status: DeliveryStatus::Blocked,
688
+ message_status: MessageStatusShadow("queued_pane_missing".to_string()),
689
+ message_id: Some(message_id.to_string()),
690
+ verification: verification.or(Some(action)),
691
+ stage: Some(DeliveryStage::Inject),
692
+ reason: Some(DeliveryRefusal::TmuxTargetMissing),
693
+ channel: Some("delivery_blocked".to_string()),
694
+ })
695
+ }
696
+
697
+ fn transport_error_is_target_missing(error: &crate::transport::TransportError) -> bool {
698
+ match error {
699
+ crate::transport::TransportError::TargetNotFound { .. } => true,
700
+ crate::transport::TransportError::Subprocess { stderr, .. } => {
701
+ let stderr = stderr.to_ascii_lowercase();
702
+ (stderr.contains("can't find") || stderr.contains("no such"))
703
+ && (stderr.contains("pane")
704
+ || stderr.contains("window")
705
+ || stderr.contains("session"))
706
+ }
707
+ _ => false,
708
+ }
709
+ }
710
+
711
+ pub fn requeue_worker_target_missing_messages(
712
+ workspace: &Path,
713
+ store: &MessageStore,
714
+ event_log: &EventLog,
715
+ recipient: &str,
716
+ owner_team_id: Option<&str>,
717
+ ) -> Result<Vec<String>, MessagingError> {
718
+ let conn = crate::db::schema::open_db(store.db_path())?;
719
+ let now = chrono::Utc::now().to_rfc3339();
720
+ let mut stmt = conn.prepare(
721
+ "select message_id from messages
722
+ where recipient = ?1
723
+ and status = 'queued_pane_missing'
724
+ and error = 'tmux_target_missing'
725
+ and (
726
+ (?2 is null and owner_team_id is null)
727
+ or owner_team_id = ?2
728
+ )
729
+ order by created_at, message_id",
730
+ )?;
731
+ let ids = stmt
732
+ .query_map(params![recipient, owner_team_id], |row| {
733
+ row.get::<_, String>(0)
734
+ })?
735
+ .collect::<Result<Vec<_>, _>>()?;
736
+ drop(stmt);
737
+ for message_id in &ids {
738
+ conn.execute(
739
+ "update messages
740
+ set status = 'accepted',
741
+ error = null,
742
+ updated_at = ?2
743
+ where message_id = ?1",
744
+ params![message_id, now.as_str()],
745
+ )?;
746
+ }
747
+ if !ids.is_empty() {
748
+ event_log.write(
749
+ "worker_receiver.blocked_messages_requeued",
750
+ serde_json::json!({
751
+ "recipient": recipient,
752
+ "owner_team_id": owner_team_id,
753
+ "count": ids.len(),
754
+ "message_ids": ids,
755
+ }),
756
+ )?;
757
+ }
758
+ let _ = workspace;
759
+ Ok(ids)
760
+ }
761
+
577
762
  /// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
578
763
  pub(crate) fn inject_submit_verified(report: &InjectReport) -> bool {
579
764
  match report.submit_verification {
@@ -1170,34 +1355,17 @@ fn delivery_transport_for_recipient<'a>(
1170
1355
  if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
1171
1356
  DeliveryTransport::Borrowed(product_transport)
1172
1357
  } else {
1358
+ // 0.5.4 gate054 cross-team leak fix: when `leader_receiver.tmux_socket`
1359
+ // is present and non-canonical, it is a hard channel boundary — the
1360
+ // caller (team A on socket X) explicitly recorded which tmux server
1361
+ // hosts its leader pane. Pane ids (`%N`) are only unique per tmux
1362
+ // server, so probing product/default servers for the same pane id and
1363
+ // switching transports on a match can cross the team/tmux boundary and
1364
+ // inject A's leader traffic into team B's leader pane. If the pane is
1365
+ // absent on the recorded endpoint, let the downstream
1366
+ // `leader_receiver_pane_is_usable` guard produce `rebind_required`
1367
+ // instead of falling back to another server.
1173
1368
  let endpoint_backend = crate::transport_factory::tmux_endpoint_transport(socket);
1174
- if let Some(pane_id) = pane_id {
1175
- if endpoint_backend
1176
- .list_targets()
1177
- .unwrap_or_default()
1178
- .iter()
1179
- .any(|target| target.pane_id.as_str() == pane_id)
1180
- {
1181
- return DeliveryTransport::Owned(Box::new(endpoint_backend));
1182
- }
1183
- if product_transport
1184
- .list_targets()
1185
- .unwrap_or_default()
1186
- .iter()
1187
- .any(|target| target.pane_id.as_str() == pane_id)
1188
- {
1189
- return DeliveryTransport::Borrowed(product_transport);
1190
- }
1191
- let default_backend = crate::transport_factory::tmux_default_transport();
1192
- if default_backend
1193
- .list_targets()
1194
- .unwrap_or_default()
1195
- .iter()
1196
- .any(|target| target.pane_id.as_str() == pane_id)
1197
- {
1198
- return DeliveryTransport::Owned(Box::new(default_backend));
1199
- }
1200
- }
1201
1369
  DeliveryTransport::Owned(Box::new(endpoint_backend))
1202
1370
  }
1203
1371
  }
@@ -1237,6 +1405,15 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
1237
1405
  leader_receiver_field(state, "tmux_socket")
1238
1406
  }
1239
1407
 
1408
+ /// 0.5.4 gate054 cross-team leak fix: read the bound `leader_receiver.status`
1409
+ /// through the same team-scope resolution as [`leader_receiver_tmux_socket`].
1410
+ /// Restart marks demoted receivers as `"rebind_required"`
1411
+ /// (lifecycle/restart/rebuild.rs:1311); delivery must refuse before injecting
1412
+ /// or cross-server pane-id probing (delivery.rs:1150-1201).
1413
+ fn leader_receiver_status(state: &serde_json::Value) -> Option<&str> {
1414
+ leader_receiver_field(state, "status")
1415
+ }
1416
+
1240
1417
  fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
1241
1418
  leader_receiver_tmux_socket(state)
1242
1419
  .is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
@@ -1494,6 +1671,7 @@ struct PendingMessage {
1494
1671
  task_id: Option<String>,
1495
1672
  owner_team_id: Option<String>,
1496
1673
  status: String,
1674
+ error: Option<String>,
1497
1675
  delivery_attempts: u32,
1498
1676
  }
1499
1677
 
@@ -1504,7 +1682,7 @@ fn message_for_delivery(
1504
1682
  let conn = crate::db::schema::open_db(store.db_path())?;
1505
1683
  let message = conn
1506
1684
  .query_row(
1507
- "select sender, recipient, content, task_id, owner_team_id, status, delivery_attempts from messages where message_id = ?1",
1685
+ "select sender, recipient, content, task_id, owner_team_id, status, error, delivery_attempts from messages where message_id = ?1",
1508
1686
  params![message_id],
1509
1687
  |row| {
1510
1688
  Ok(PendingMessage {
@@ -1514,7 +1692,8 @@ fn message_for_delivery(
1514
1692
  task_id: row.get::<_, Option<String>>(3)?,
1515
1693
  owner_team_id: row.get::<_, Option<String>>(4)?,
1516
1694
  status: row.get::<_, String>(5)?,
1517
- delivery_attempts: row.get::<_, i64>(6)?.max(0) as u32,
1695
+ error: row.get::<_, Option<String>>(6)?,
1696
+ delivery_attempts: row.get::<_, i64>(7)?.max(0) as u32,
1518
1697
  })
1519
1698
  },
1520
1699
  )
@@ -8,7 +8,7 @@ use serde_json::Value;
8
8
  use crate::event_log::EventLog;
9
9
  use crate::message_store::{MessageStore, NotificationClaimParams};
10
10
  use crate::model::ids::TaskId;
11
- use crate::transport::{InjectPayload, Key, PaneId, Target, Transport};
11
+ use crate::transport::{InjectPayload, InjectReport, Key, PaneId, Target, Transport, TransportError};
12
12
 
13
13
  use super::helpers::MessageStatusShadow;
14
14
  use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError};
@@ -286,27 +286,41 @@ pub fn deliver_to_leader_fallback_pane(
286
286
  let rendered = render_fallback_pane_message(content, message_id, primary_error);
287
287
  let target = Target::Pane(PaneId::new(&pane_id));
288
288
  let payload = InjectPayload::Text(rendered);
289
- // 0.5.x Phase 1d Batch 4: leader-fallback pane inject uses the
290
- // factory tmux channel helpers so `grep transport_factory::tmux_`
291
- // enumerates every tmux-only leader-channel site. Semantics are
292
- // identical to the previous direct `TmuxBackend::*` calls
293
- // (helpers are thin wrappers). `transport_kind`-based dispatch
294
- // is the parallel `feat/appserver-leader-host` work; this batch
295
- // stays helper-swap only.
296
- let inject_result = leader_tmux_socket(state)
297
- .and_then(|socket| {
289
+ // 0.5.5 gate054 cross-team notify boundary: when the leader receiver
290
+ // has a recorded `tmux_socket` (canonical), that socket is a HARD
291
+ // channel boundary. Pane ids (`%N`) are only unique per tmux server,
292
+ // so falling back from a missing/dead pane on the recorded endpoint
293
+ // to the workspace-canonical or default tmux server can inject team
294
+ // A's leader traffic into team B's leader pane on a different socket
295
+ // (real-machine gate054 acceptance B-arm: private socket %1 absent
296
+ // after leader kill, default server's %1 was the main leader — the
297
+ // prior chain silently landed A's `report_result` there).
298
+ //
299
+ // Loud-not-silent (user tie-break E23 / N32): if the recorded endpoint
300
+ // rejects the inject we return `Blocked` with `channel=rebind_required`
301
+ // and audit `leader_receiver.fallback_pane_failed` — the message row
302
+ // stays pending in the store, coordinator surfaces the pending
303
+ // notification via status/monitor, and no cross-server injection is
304
+ // attempted.
305
+ let inject_result: Result<InjectReport, TransportError> = match leader_tmux_socket(state) {
306
+ Some(socket) => {
298
307
  let backend = crate::transport_factory::tmux_endpoint_transport(socket);
299
- backend.inject(&target, &payload, Key::Enter, true).ok()
300
- })
301
- .map(Ok)
302
- .unwrap_or_else(|| {
303
- let backend = crate::transport_factory::tmux_workspace_transport(workspace);
304
- backend.inject(&target, &payload, Key::Enter, true)
305
- })
306
- .or_else(|_| {
307
- let backend = crate::transport_factory::tmux_default_transport();
308
308
  backend.inject(&target, &payload, Key::Enter, true)
309
- });
309
+ }
310
+ None => {
311
+ // No socket recorded → legacy (pre-0.5) shape where the receiver
312
+ // was implicitly bound to the workspace-canonical tmux server.
313
+ // Keep the workspace→default chain here (single team assumption)
314
+ // but never mix it with a recorded-socket team.
315
+ let backend = crate::transport_factory::tmux_workspace_transport(workspace);
316
+ backend
317
+ .inject(&target, &payload, Key::Enter, true)
318
+ .or_else(|_| {
319
+ let backend = crate::transport_factory::tmux_default_transport();
320
+ backend.inject(&target, &payload, Key::Enter, true)
321
+ })
322
+ }
323
+ };
310
324
 
311
325
  match inject_result {
312
326
  Ok(report) => {
@@ -367,6 +381,14 @@ pub fn deliver_to_leader_fallback_pane(
367
381
  }
368
382
  }
369
383
  Err(error) => {
384
+ // 0.5.5 gate054: when a leader_receiver.tmux_socket is recorded,
385
+ // a fallback inject error is a HARD team-boundary event — we do
386
+ // not cross to workspace/default tmux (see the inject_result
387
+ // branch above). Surface `rebind_required` so callers wire the
388
+ // notification status accordingly; the row stays pending in the
389
+ // store and the audit event carries the same forensic fields as
390
+ // the pre-fix path.
391
+ let socket_bound = leader_tmux_socket(state).is_some();
370
392
  event_log.write(
371
393
  "leader_receiver.fallback_pane_failed",
372
394
  serde_json::json!({
@@ -377,17 +399,28 @@ pub fn deliver_to_leader_fallback_pane(
377
399
  "primary_error": primary_error,
378
400
  "delivered_via": "fallback_pane",
379
401
  "reason": error.to_string(),
402
+ "socket_bound": socket_bound,
380
403
  }),
381
404
  )?;
405
+ // Row status is left untouched so status/monitor surfaces the
406
+ // pending notification until the operator rebinds the leader.
407
+ let (status, channel) = if socket_bound {
408
+ (
409
+ DeliveryStatus::Blocked,
410
+ "rebind_required".to_string(),
411
+ )
412
+ } else {
413
+ (DeliveryStatus::Failed, "fallback_pane".to_string())
414
+ };
382
415
  Ok(DeliveryOutcome {
383
416
  ok: false,
384
- status: DeliveryStatus::Failed,
417
+ status,
385
418
  message_status: MessageStatusShadow("failed".to_string()),
386
419
  message_id: Some(message_id.to_string()),
387
420
  verification: Some(error.to_string()),
388
421
  stage: None,
389
422
  reason: Some(DeliveryRefusal::TmuxTargetMissing),
390
- channel: Some("fallback_pane".to_string()),
423
+ channel: Some(channel),
391
424
  })
392
425
  }
393
426
  }
@@ -445,9 +478,11 @@ pub(crate) fn leader_pane_bound_but_not_live(workspace: &Path, state: &Value) ->
445
478
  }
446
479
 
447
480
  fn leader_pane_is_live(workspace: &Path, state: &Value, pane_id: &str) -> bool {
448
- // 0.5.x Phase 1d Batch 4: leader liveness uses factory tmux
449
- // channel helpers. Semantics unchanged; `transport_kind`-based
450
- // dispatch is the parallel feat/appserver-leader-host work.
481
+ // 0.5.5 gate054 boundary: when a socket is recorded, liveness is
482
+ // scoped to that endpoint. Never union with workspace/default targets
483
+ // (pane ids are only unique per tmux server; a stray same-numbered
484
+ // pane on another server would falsely report "live" and steer the
485
+ // fallback path into a cross-team inject).
451
486
  if let Some(socket) = leader_tmux_socket(state) {
452
487
  return crate::transport_factory::tmux_endpoint_transport(socket)
453
488
  .list_targets()
@@ -1683,6 +1683,58 @@ fn slice1_inject_failures_are_bounded_and_stop_retrying_same_message() {
1683
1683
  );
1684
1684
  }
1685
1685
 
1686
+ #[test]
1687
+ fn target_missing_session_window_blocks_without_generic_inject_exhaustion() {
1688
+ let ws = tmp_ws("targetmissing");
1689
+ let store = store_for(&ws);
1690
+ let log = EventLog::new(&ws);
1691
+ let state = serde_json::json!({
1692
+ "session_name": "team-targetmissing",
1693
+ "leader_receiver": {"pane_id": "%leader"},
1694
+ "agents": {"w1": {"provider": "fake", "window": "w1"}}
1695
+ });
1696
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
1697
+ let message_id = store
1698
+ .create_message(None, "leader", "w1", "ping", None, false, None)
1699
+ .unwrap();
1700
+ let transport = OfflineTransport::new()
1701
+ .with_session_present(true)
1702
+ .with_targets(vec![pane_info("%2", "team-targetmissing", "other")]);
1703
+
1704
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
1705
+ .expect("target-missing classification should be a delivery outcome");
1706
+
1707
+ assert!(!out.ok);
1708
+ assert_eq!(out.status, DeliveryStatus::Blocked);
1709
+ assert_eq!(out.message_status.0, "queued_pane_missing");
1710
+ assert_eq!(out.reason, Some(DeliveryRefusal::TmuxTargetMissing));
1711
+ assert!(
1712
+ transport.inject_targets().is_empty(),
1713
+ "target-missing must block before physical inject; targets={:?}",
1714
+ transport.inject_targets()
1715
+ );
1716
+
1717
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
1718
+ let (status, error): (String, Option<String>) = conn
1719
+ .query_row(
1720
+ "select status, error from messages where message_id = ?1",
1721
+ [&message_id],
1722
+ |row| Ok((row.get(0)?, row.get(1)?)),
1723
+ )
1724
+ .unwrap();
1725
+ assert_eq!(status, "queued_pane_missing");
1726
+ assert_eq!(error.as_deref(), Some("tmux_target_missing"));
1727
+ let events = log.tail(0).unwrap();
1728
+ assert!(
1729
+ events.iter().any(|event| {
1730
+ event.get("event").and_then(serde_json::Value::as_str) == Some("send.inject_blocked")
1731
+ && event.get("reason").and_then(serde_json::Value::as_str)
1732
+ == Some("tmux_target_missing")
1733
+ }),
1734
+ "target-missing block must be explicit in events; events={events:?}"
1735
+ );
1736
+ }
1737
+
1686
1738
  #[test]
1687
1739
  fn u1_projection_refused_emits_delivery_event() {
1688
1740
  let ws = tmp_ws("u1projref");
@@ -2730,3 +2782,397 @@ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
2730
2782
  "E23: private direct inject must stay deleted; all callers use deliver_to_leader_fallback_pane"
2731
2783
  );
2732
2784
  }
2785
+
2786
+ // ════════════════════════════════════════════════════════════════════════
2787
+ // 0.5.4 gate054 — cross-team notify boundary.
2788
+ //
2789
+ // Triage: .team/artifacts/0.5.4-gate-crossteam-notify-triage.md
2790
+ //
2791
+ // Shape (regression):
2792
+ // - Gate team's `leader_receiver` records `pane_id=%1`, `tmux_socket=/tmp/gate`,
2793
+ // `status="rebind_required"` (disposable leader was killed post-restart).
2794
+ // - The main tmux server has a live `%1` on the workspace-canonical socket.
2795
+ // - `deliver_pending_message` MUST refuse with `channel=rebind_required` and
2796
+ // MUST NOT probe the main/default tmux server for the same pane id.
2797
+ // ════════════════════════════════════════════════════════════════════════
2798
+
2799
+ #[test]
2800
+ fn gate054_rebind_required_status_refuses_leader_delivery_without_cross_server_probe() {
2801
+ let ws = tmp_ws("gate054status");
2802
+ let store = store_for(&ws);
2803
+ let log = EventLog::new(&ws);
2804
+ // Explicit endpoint socket (non-canonical for this workspace) with a
2805
+ // stale receiver demoted to `rebind_required` after the gate's leader
2806
+ // window was killed.
2807
+ let state = serde_json::json!({
2808
+ "session_name": "team-gate054",
2809
+ "tmux_endpoint": "/tmp/gate-socket-does-not-exist",
2810
+ "leader_receiver": {
2811
+ "pane_id": "%1",
2812
+ "tmux_socket": "/tmp/gate-socket-does-not-exist",
2813
+ "status": "rebind_required"
2814
+ },
2815
+ "agents": {
2816
+ "probe": {"provider": "fake", "pane_id": "%0", "window": "probe"}
2817
+ }
2818
+ });
2819
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2820
+ let message_id = store
2821
+ .create_message(
2822
+ None,
2823
+ "probe",
2824
+ "leader",
2825
+ "gate054 must not leak",
2826
+ None,
2827
+ false,
2828
+ None,
2829
+ )
2830
+ .unwrap();
2831
+
2832
+ // The transport we pass is the "main"/default server backend which does
2833
+ // have a live `%1`. If the fix regresses and delivery falls back cross-
2834
+ // server by pane id, it would inject here.
2835
+ let default_backend = OfflineTransport::new()
2836
+ .with_targets(vec![pane_info("%1", "team-main", "leader")]);
2837
+
2838
+ let out = deliver_pending_message(&ws, &store, &default_backend, &message_id, &log, &state)
2839
+ .expect("status guard must be a business refusal, not panic");
2840
+
2841
+ assert!(
2842
+ !out.ok,
2843
+ "0.5.4 gate054: leader_receiver.status='rebind_required' must refuse delivery"
2844
+ );
2845
+ assert_eq!(out.channel.as_deref(), Some("rebind_required"));
2846
+ assert_eq!(out.message_status.0, "failed");
2847
+ assert_eq!(out.reason, Some(DeliveryRefusal::LeaderNotAttached));
2848
+ // Cross-server invariant: even though the default backend has a live `%1`
2849
+ // matching the recorded receiver pane id, delivery MUST NOT inject there.
2850
+ assert!(
2851
+ default_backend.inject_targets().is_empty(),
2852
+ "0.5.4 gate054: leader delivery must not cross to a different tmux server by pane id; \
2853
+ inject_targets={:?}",
2854
+ default_backend.inject_targets()
2855
+ );
2856
+ let events = log.tail(0).unwrap();
2857
+ assert!(
2858
+ events.iter().any(|event| {
2859
+ event.get("event").and_then(serde_json::Value::as_str)
2860
+ == Some("leader_receiver.delivery_blocked")
2861
+ && event.get("reason").and_then(serde_json::Value::as_str)
2862
+ == Some("leader_receiver_not_attached")
2863
+ && event.get("channel").and_then(serde_json::Value::as_str)
2864
+ == Some("rebind_required")
2865
+ && event.get("status").and_then(serde_json::Value::as_str)
2866
+ == Some("rebind_required")
2867
+ }),
2868
+ "0.5.4 gate054: refused status must audit as leader_receiver_not_attached / rebind_required; events={events:?}"
2869
+ );
2870
+ }
2871
+
2872
+ #[test]
2873
+ fn gate054_attached_status_still_delivers_when_pane_is_live() {
2874
+ // Companion negative: prove the new status gate does NOT over-refuse when
2875
+ // `status="attached"` and the workspace-canonical socket has the pane.
2876
+ let ws = tmp_ws("gate054ok");
2877
+ let store = store_for(&ws);
2878
+ let log = EventLog::new(&ws);
2879
+ let state = serde_json::json!({
2880
+ "session_name": "team-gate054ok",
2881
+ "leader_receiver": {
2882
+ "pane_id": "%leader",
2883
+ "status": "attached"
2884
+ },
2885
+ "agents": {"w1": {"provider": "fake", "pane_id": "%1"}}
2886
+ });
2887
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2888
+ let message_id = store
2889
+ .create_message(None, "w1", "leader", "hi", None, false, None)
2890
+ .unwrap();
2891
+ let transport = OfflineTransport::new()
2892
+ .with_targets(vec![pane_info("%leader", "team-gate054ok", "leader")]);
2893
+
2894
+ let out = deliver_pending_message(&ws, &store, &transport, &message_id, &log, &state)
2895
+ .expect("attached leader must still deliver");
2896
+
2897
+ assert!(
2898
+ out.ok,
2899
+ "0.5.4 gate054: status='attached' with a live pane must not be over-refused: {out:?}"
2900
+ );
2901
+ assert_eq!(out.message_status.0, "delivered");
2902
+ }
2903
+
2904
+ // ════════════════════════════════════════════════════════════════════════
2905
+ // 0.5.5 gate054 B-arm — deliver_to_leader_fallback_pane must not cross to
2906
+ // another tmux server by pane id when a socket is recorded.
2907
+ //
2908
+ // Triage: real-machine acceptance B-fail — primary path (delivery.rs) refused
2909
+ // with rebind_required, but report_result's fallback_pane bailout selected a
2910
+ // same-numbered pane (%1) on the default tmux server (the main team's leader)
2911
+ // and returned leader_notified:true / delivered_via=fallback_pane.
2912
+ // ════════════════════════════════════════════════════════════════════════
2913
+
2914
+ #[test]
2915
+ fn gate054_fallback_pane_socket_recorded_does_not_cross_to_default_server() {
2916
+ use crate::messaging::deliver_to_leader_fallback_pane;
2917
+ let ws = tmp_ws("gate054fallback");
2918
+ let store = store_for(&ws);
2919
+ let log = EventLog::new(&ws);
2920
+ // Recorded socket points at a non-existent endpoint (mimics the gate
2921
+ // team's private socket after its leader session was killed). The main
2922
+ // team's default tmux server MAY have a same-numbered `%1` — the fix
2923
+ // must refuse to inject there.
2924
+ let bogus_socket = format!("/tmp/team-agent-gate054-nonexistent-{}", std::process::id());
2925
+ let state = serde_json::json!({
2926
+ "session_name": "team-gate054",
2927
+ "leader_receiver": {
2928
+ "pane_id": "%1",
2929
+ "tmux_socket": bogus_socket,
2930
+ "status": "rebind_required"
2931
+ }
2932
+ });
2933
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
2934
+ let message_id = store
2935
+ .create_message(None, "coordinator", "leader", "fallback payload", None, false, None)
2936
+ .unwrap();
2937
+
2938
+ let outcome = deliver_to_leader_fallback_pane(
2939
+ &ws,
2940
+ &state,
2941
+ &message_id,
2942
+ Some("res_gate054_fallback"),
2943
+ "S3 restart complete",
2944
+ false, // primary was NOT ok (report_result path)
2945
+ Some("primary_delivery_error:leader_not_attached"),
2946
+ &log,
2947
+ )
2948
+ .expect("fallback pane primitive must not panic");
2949
+
2950
+ // Must NOT succeed by crossing to another server.
2951
+ assert!(
2952
+ !outcome.ok,
2953
+ "0.5.5 gate054: fallback_pane must not cross a recorded socket boundary: outcome={outcome:?}"
2954
+ );
2955
+ // Loud-not-silent: caller wire status resolves to rebind_required
2956
+ // (results.rs matches channel or Blocked → 'rebind_required').
2957
+ assert!(
2958
+ outcome.channel.as_deref() == Some("rebind_required")
2959
+ || matches!(outcome.status, DeliveryStatus::Blocked),
2960
+ "0.5.5 gate054: cross-boundary refusal must surface as rebind_required, got outcome={outcome:?}"
2961
+ );
2962
+ // Audit noise: fallback_pane_attempt + fallback_pane_failed with the
2963
+ // recorded socket_bound=true forensic field.
2964
+ let events = log.tail(0).unwrap();
2965
+ let attempt = events
2966
+ .iter()
2967
+ .find(|e| {
2968
+ e.get("event").and_then(serde_json::Value::as_str)
2969
+ == Some("leader_receiver.fallback_pane_attempt")
2970
+ });
2971
+ assert!(
2972
+ attempt.is_some(),
2973
+ "fallback_pane_attempt audit must fire even on socket-bounded refusal; events={events:?}"
2974
+ );
2975
+ let failed = events.iter().find(|e| {
2976
+ e.get("event").and_then(serde_json::Value::as_str)
2977
+ == Some("leader_receiver.fallback_pane_failed")
2978
+ });
2979
+ assert!(
2980
+ failed.is_some(),
2981
+ "fallback_pane_failed audit must fire when the recorded socket rejects inject; events={events:?}"
2982
+ );
2983
+ assert_eq!(
2984
+ failed
2985
+ .and_then(|e| e.get("socket_bound"))
2986
+ .and_then(serde_json::Value::as_bool),
2987
+ Some(true),
2988
+ "socket_bound forensic field must record the boundary state"
2989
+ );
2990
+ }
2991
+
2992
+ #[test]
2993
+ fn gate054_rebind_replay_requeues_failed_leader_message_on_attach() {
2994
+ // Round-2 real-machine shape: direct report_result → primary refused with
2995
+ // rebind_required → row is `status=failed, error=leader_not_attached`.
2996
+ // `attach-leader` must requeue it back to `status=accepted` so the
2997
+ // coordinator tick's `deliver_pending_messages` replays it to the new
2998
+ // leader pane. Same message_id — no second replay mechanism, no new
2999
+ // watcher, exactly-once via leader_notification_log PK.
3000
+ use crate::model::ids::TeamKey;
3001
+ let ws = tmp_ws("gate054replay");
3002
+ let store = store_for(&ws);
3003
+ let log = EventLog::new(&ws);
3004
+ let team_id = "gate055r2b";
3005
+
3006
+ // Seed the same shape the primary-path refusal leaves behind.
3007
+ let message_id = store
3008
+ .create_message(
3009
+ None,
3010
+ "probe-worker",
3011
+ "leader",
3012
+ "B2_NOTIFY report_result payload",
3013
+ None,
3014
+ false,
3015
+ Some(team_id),
3016
+ )
3017
+ .unwrap();
3018
+ store
3019
+ .mark(&message_id, "failed", Some("leader_not_attached"))
3020
+ .unwrap();
3021
+ // Sanity: precondition matches acceptance evidence.
3022
+ let conn = crate::db::schema::open_db(store.db_path()).unwrap();
3023
+ let (status_before, error_before): (String, Option<String>) = conn
3024
+ .query_row(
3025
+ "select status, error from messages where message_id = ?1",
3026
+ [&message_id],
3027
+ |r| Ok((r.get(0)?, r.get(1)?)),
3028
+ )
3029
+ .unwrap();
3030
+ assert_eq!(status_before, "failed");
3031
+ assert_eq!(error_before.as_deref(), Some("leader_not_attached"));
3032
+
3033
+ // The attach-leader path calls requeue_delivery_exhausted_watchers with
3034
+ // the newly-claimed pane. Even with NO exhausted watchers to requeue,
3035
+ // it must flip the blocked leader row back to accepted.
3036
+ let team = TeamKey::new(team_id);
3037
+ let new_pane = PaneId::new("%1"); // new leader window on the private socket
3038
+ let notices = crate::messaging::requeue_delivery_exhausted_watchers(
3039
+ &ws, &store, &log, &team, &new_pane,
3040
+ )
3041
+ .unwrap();
3042
+ assert!(
3043
+ notices.is_empty(),
3044
+ "no exhausted watchers seeded → no watcher notices; only the message row should have been requeued"
3045
+ );
3046
+
3047
+ let (status_after, error_after): (String, Option<String>) = conn
3048
+ .query_row(
3049
+ "select status, error from messages where message_id = ?1",
3050
+ [&message_id],
3051
+ |r| Ok((r.get(0)?, r.get(1)?)),
3052
+ )
3053
+ .unwrap();
3054
+ assert_eq!(
3055
+ status_after, "accepted",
3056
+ "gate054 round-2: rebind_required leader row must flip back to 'accepted' on attach-leader"
3057
+ );
3058
+ assert!(
3059
+ error_after.is_none(),
3060
+ "gate054 round-2: error must be cleared on requeue so deliver_pending_message replays cleanly"
3061
+ );
3062
+
3063
+ // Audit noise: leader_receiver.blocked_messages_requeued fired with the
3064
+ // exact team + claimed pane + count for status/monitor observability.
3065
+ let events = log.tail(0).unwrap();
3066
+ let requeued = events.iter().find(|e| {
3067
+ e.get("event").and_then(serde_json::Value::as_str)
3068
+ == Some("leader_receiver.blocked_messages_requeued")
3069
+ });
3070
+ assert!(
3071
+ requeued.is_some(),
3072
+ "attach-leader requeue of blocked leader messages must audit; events={events:?}"
3073
+ );
3074
+ assert_eq!(
3075
+ requeued
3076
+ .and_then(|e| e.get("count"))
3077
+ .and_then(serde_json::Value::as_u64),
3078
+ Some(1)
3079
+ );
3080
+ assert_eq!(
3081
+ requeued
3082
+ .and_then(|e| e.get("team_id"))
3083
+ .and_then(serde_json::Value::as_str),
3084
+ Some(team_id)
3085
+ );
3086
+ }
3087
+
3088
+ #[test]
3089
+ fn gate054_status_surfaces_pending_leader_notifications() {
3090
+ // Round-2: user-facing visibility — status must show the blocked leader
3091
+ // notification as pending, not just as `messages.failed=1`.
3092
+ use crate::cli::status_port::status_scoped;
3093
+ let ws = tmp_ws("gate054status");
3094
+ let store = store_for(&ws);
3095
+ let team_id = "gate055r2b";
3096
+
3097
+ let message_id = store
3098
+ .create_message(
3099
+ None,
3100
+ "probe-worker",
3101
+ "leader",
3102
+ "B2_NOTIFY report_result payload",
3103
+ None,
3104
+ false,
3105
+ Some(team_id),
3106
+ )
3107
+ .unwrap();
3108
+ store
3109
+ .mark(&message_id, "failed", Some("leader_not_attached"))
3110
+ .unwrap();
3111
+
3112
+ // Minimal runtime state so status can render.
3113
+ let state = serde_json::json!({
3114
+ "session_name": format!("team-{team_id}"),
3115
+ "active_team_key": team_id,
3116
+ "teams": {team_id: {"team_key": team_id}},
3117
+ "leader_receiver": {
3118
+ "pane_id": "%1",
3119
+ "status": "rebind_required",
3120
+ }
3121
+ });
3122
+ crate::state::persist::save_runtime_state(&ws, &state).unwrap();
3123
+
3124
+ let payload = status_scoped(&ws, &state, Some(team_id), false, false).unwrap();
3125
+ let pending = payload
3126
+ .get("pending_leader_notifications")
3127
+ .and_then(serde_json::Value::as_array)
3128
+ .expect("status must expose pending_leader_notifications as an array");
3129
+ assert_eq!(
3130
+ pending.len(),
3131
+ 1,
3132
+ "the blocked leader message must appear in pending_leader_notifications"
3133
+ );
3134
+ let entry = &pending[0];
3135
+ assert_eq!(
3136
+ entry.get("message_id").and_then(serde_json::Value::as_str),
3137
+ Some(message_id.as_str())
3138
+ );
3139
+ assert_eq!(
3140
+ entry.get("channel").and_then(serde_json::Value::as_str),
3141
+ Some("rebind_required")
3142
+ );
3143
+ assert!(entry
3144
+ .get("action")
3145
+ .and_then(serde_json::Value::as_str)
3146
+ .is_some_and(|action| action.contains("attach-leader")
3147
+ || action.contains("takeover")));
3148
+ }
3149
+
3150
+ #[test]
3151
+ fn gate054_fallback_pane_grep_guard_no_cross_server_chain_when_socket_recorded() {
3152
+ // Structural grep: the recorded-socket branch of the fallback inject
3153
+ // chain must NOT compose the workspace or default tmux backends after
3154
+ // the endpoint backend rejects. This is the byte-level fence against
3155
+ // silent regression.
3156
+ let src = include_str!("../leader_receiver.rs");
3157
+ let inject_start = src
3158
+ .find("let inject_result: Result<InjectReport, TransportError>")
3159
+ .expect("fallback inject chain must remain single-typed");
3160
+ let inject_end = src[inject_start..]
3161
+ .find("};\n\n match inject_result")
3162
+ .map(|off| inject_start + off + 2)
3163
+ .expect("fallback inject chain must terminate before match");
3164
+ let chain = &src[inject_start..inject_end];
3165
+ // The socket-recorded arm is the `Some(socket) => {..}` block. It must
3166
+ // NOT contain a workspace or default fallback.
3167
+ let some_arm_start = chain.find("Some(socket) => {").expect("Some(socket) arm");
3168
+ let some_arm_end = chain[some_arm_start..]
3169
+ .find("None => {")
3170
+ .map(|off| some_arm_start + off)
3171
+ .expect("None arm must follow");
3172
+ let some_arm = &chain[some_arm_start..some_arm_end];
3173
+ assert!(
3174
+ !some_arm.contains("tmux_workspace_transport")
3175
+ && !some_arm.contains("tmux_default_transport"),
3176
+ "0.5.5 gate054: socket-recorded fallback arm must not compose workspace/default backends; arm={some_arm}"
3177
+ );
3178
+ }
@@ -436,45 +436,61 @@ pub fn requeue_after_claim_leader(
436
436
  }),
437
437
  )?;
438
438
  }
439
- // #231 C-5: requeue blocked leader-bound messages that hit I-4 `rebind_required`
440
- // while no leader pane was attached. Same row, same message_id — flip status back
441
- // from `failed`/`leader_not_attached` to `accepted` so `deliver_pending_messages`
442
- // replays it through the SAME pipeline. The leader_notification_log PK is already
443
- // there (primitive wrote it before the unbound check), so this replay can't create
444
- // a duplicate notification — exactly-once across rebind, no new send/notify rows.
445
- let requeued_blocked = conn.execute(
439
+ let requeued_blocked =
440
+ requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
441
+ if !out.is_empty() || requeued_blocked > 0 {
442
+ let _ = retry_result_deliveries(workspace, event_log)?;
443
+ }
444
+ Ok(out)
445
+ }
446
+
447
+ /// 0.5.5 gate054 round-2: attach-leader (and claim-leader) requeue for leader messages
448
+ /// that were refused with `rebind_required` while no leader pane was attached.
449
+ ///
450
+ /// #231 C-5 semantics: same row, same message_id — flip `status` back from
451
+ /// `failed`/`leader_not_attached` to `accepted` so `deliver_pending_messages`
452
+ /// replays it through the SAME pipeline. The `leader_notification_log` PK is
453
+ /// already there (primitive wrote it before the unbound check), so this replay
454
+ /// cannot create a duplicate notification — exactly-once across rebind, no new
455
+ /// send/notify rows and no second replay mechanism.
456
+ pub(crate) fn requeue_blocked_leader_messages(
457
+ conn: &rusqlite::Connection,
458
+ event_log: &EventLog,
459
+ owner_team_id: &TeamKey,
460
+ claimed_pane_id: &PaneId,
461
+ ) -> Result<usize, MessagingError> {
462
+ let requeued = conn.execute(
446
463
  "update messages
447
464
  set status = 'accepted',
448
465
  error = null,
449
- updated_at = ?3
466
+ updated_at = ?2
450
467
  where recipient = 'leader'
451
468
  and status = 'failed'
452
469
  and error = 'leader_not_attached'
453
470
  and owner_team_id = ?1",
454
- params![
455
- owner_team_id.as_str(),
456
- claimed_pane_id.as_str(),
457
- chrono::Utc::now().to_rfc3339(),
458
- ],
471
+ params![owner_team_id.as_str(), chrono::Utc::now().to_rfc3339()],
459
472
  )?;
460
- if requeued_blocked > 0 {
473
+ if requeued > 0 {
461
474
  event_log.write(
462
475
  "leader_receiver.blocked_messages_requeued",
463
476
  serde_json::json!({
464
477
  "team_id": owner_team_id.as_str(),
465
478
  "claimed_pane_id": claimed_pane_id.as_str(),
466
- "count": requeued_blocked,
479
+ "count": requeued,
467
480
  }),
468
481
  )?;
469
482
  }
470
- if !out.is_empty() {
471
- let _ = retry_result_deliveries(workspace, event_log)?;
472
- }
473
- Ok(out)
483
+ Ok(requeued)
474
484
  }
475
485
 
476
486
  /// `requeue_delivery_exhausted_watchers`: attach-leader 成功后把已经耗尽投递
477
487
  /// 重试的 watcher 放回 notify_failed, 留给 coordinator tick 重试投递。
488
+ ///
489
+ /// 0.5.5 gate054 round-2: also requeue direct leader messages that were refused
490
+ /// with `rebind_required` (status=failed / error=leader_not_attached). Without
491
+ /// this the direct `report_result` path leaves a failed row that
492
+ /// `deliver_pending_messages` never re-picks — the new leader would never see the
493
+ /// pending notification.
478
494
  pub fn requeue_delivery_exhausted_watchers(
479
495
  _workspace: &Path,
480
496
  store: &MessageStore,
@@ -522,6 +538,7 @@ pub fn requeue_delivery_exhausted_watchers(
522
538
  )?;
523
539
  }
524
540
  drop(stmt);
541
+ let _ = requeue_blocked_leader_messages(&conn, event_log, owner_team_id, claimed_pane_id)?;
525
542
  Ok(out)
526
543
  }
527
544
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.4",
24
- "@team-agent/cli-darwin-x64": "0.5.4",
25
- "@team-agent/cli-linux-x64": "0.5.4"
23
+ "@team-agent/cli-darwin-arm64": "0.5.5",
24
+ "@team-agent/cli-darwin-x64": "0.5.5",
25
+ "@team-agent/cli-linux-x64": "0.5.5"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",