@team-agent/installer 0.5.4 → 0.5.6

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.
@@ -21,9 +21,8 @@ use crate::messaging::{self, MessageTarget, SendOptions};
21
21
 
22
22
  use super::helpers::{
23
23
  delivery_outcome_value, ensure_object, enum_value, insert_array, is_worker_recipient,
24
- json_dumps_default, latest_task_for_assignee, latest_uncorrelated_delivered_message_for,
25
- non_empty_string, normalized_envelope_value, object_fields, requires_ack_for_target,
26
- tool_runtime_error,
24
+ json_dumps_default, latest_reportable_message_for, latest_task_for_assignee, non_empty_string,
25
+ normalized_envelope_value, object_fields, requires_ack_for_target, tool_runtime_error,
27
26
  };
28
27
  use super::normalize::{
29
28
  compact_tool_result, normalize_report_envelope, normalize_result_status_observed,
@@ -326,9 +325,9 @@ impl TeamOrchestratorTools {
326
325
  // 1. explicit arg
327
326
  // 2. latest nonterminal task assigned to this agent in
328
327
  // teams.<owner>.tasks (scoped) → top-level tasks
329
- // 3. latest delivered direct message to this agent with no
330
- // task id and no result yet (message-scope correlation —
331
- // collect path: is_message_scoped_result)
328
+ // 3. current/in-flight reportable direct message to this agent
329
+ // with no task id and no result yet (message-scope
330
+ // correlation — collect path: is_message_scoped_result)
332
331
  // 4. "manual" — truly uncorrelated; collect still rejects
333
332
  let owner_team_id_str = self.owner_team_id.as_ref().map(|t| t.as_str().to_string());
334
333
  let resolved = task_id
@@ -344,7 +343,7 @@ impl TeamOrchestratorTools {
344
343
  })
345
344
  .or_else(|| {
346
345
  self.agent_id.as_ref().and_then(|agent| {
347
- latest_uncorrelated_delivered_message_for(
346
+ latest_reportable_message_for(
348
347
  &self.workspace,
349
348
  agent.as_str(),
350
349
  owner_team_id_str.as_deref(),
@@ -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!({
@@ -405,6 +460,14 @@ pub fn deliver_pending_message(
405
460
  });
406
461
  }
407
462
  };
463
+ record_current_turn_after_inject_if_leader_to_worker_scoped(
464
+ workspace,
465
+ &message.sender,
466
+ &message.recipient,
467
+ message_id,
468
+ event_log,
469
+ canonical_owner_team_id.as_deref(),
470
+ )?;
408
471
  let submit_verified = inject_submit_verified(&inject_report);
409
472
  let readback_verified = pane_readback_verified(&inject_report);
410
473
  // Leader pane: inject success is delivery proof. Worker pane: post-submit
@@ -574,6 +637,136 @@ pub fn deliver_pending_message(
574
637
  Ok(outcome)
575
638
  }
576
639
 
640
+ fn block_missing_worker_target(
641
+ store: &MessageStore,
642
+ event_log: &EventLog,
643
+ message_id: &str,
644
+ recipient: &str,
645
+ target: &Target,
646
+ live_targets: &[PaneInfo],
647
+ ) -> Result<Option<DeliveryOutcome>, MessagingError> {
648
+ let Target::SessionWindow { session, window } = target else {
649
+ return Ok(None);
650
+ };
651
+ if live_targets.is_empty() {
652
+ return Ok(None);
653
+ }
654
+ if window.as_str().ends_with("_pane_conflicts_with_leader") {
655
+ return Ok(None);
656
+ }
657
+ if live_targets.iter().any(|pane| {
658
+ pane.session.as_str() == session.as_str()
659
+ && pane
660
+ .window_name
661
+ .as_ref()
662
+ .is_some_and(|name| name.as_str() == window.as_str())
663
+ }) {
664
+ return Ok(None);
665
+ }
666
+ mark_worker_target_missing(store, event_log, message_id, recipient, None).map(Some)
667
+ }
668
+
669
+ fn mark_worker_target_missing(
670
+ store: &MessageStore,
671
+ event_log: &EventLog,
672
+ message_id: &str,
673
+ recipient: &str,
674
+ verification: Option<String>,
675
+ ) -> Result<DeliveryOutcome, MessagingError> {
676
+ let action = format!("run team-agent start-agent {recipient} --allow-fresh");
677
+ store.mark(
678
+ message_id,
679
+ "queued_pane_missing",
680
+ Some("tmux_target_missing"),
681
+ )?;
682
+ event_log.write(
683
+ "send.inject_blocked",
684
+ serde_json::json!({
685
+ "message_id": message_id,
686
+ "recipient": recipient,
687
+ "reason": "tmux_target_missing",
688
+ "channel": "delivery_blocked",
689
+ "action": action,
690
+ "verification": verification,
691
+ }),
692
+ )?;
693
+ Ok(DeliveryOutcome {
694
+ ok: false,
695
+ status: DeliveryStatus::Blocked,
696
+ message_status: MessageStatusShadow("queued_pane_missing".to_string()),
697
+ message_id: Some(message_id.to_string()),
698
+ verification: verification.or(Some(action)),
699
+ stage: Some(DeliveryStage::Inject),
700
+ reason: Some(DeliveryRefusal::TmuxTargetMissing),
701
+ channel: Some("delivery_blocked".to_string()),
702
+ })
703
+ }
704
+
705
+ fn transport_error_is_target_missing(error: &crate::transport::TransportError) -> bool {
706
+ match error {
707
+ crate::transport::TransportError::TargetNotFound { .. } => true,
708
+ crate::transport::TransportError::Subprocess { stderr, .. } => {
709
+ let stderr = stderr.to_ascii_lowercase();
710
+ (stderr.contains("can't find") || stderr.contains("no such"))
711
+ && (stderr.contains("pane")
712
+ || stderr.contains("window")
713
+ || stderr.contains("session"))
714
+ }
715
+ _ => false,
716
+ }
717
+ }
718
+
719
+ pub fn requeue_worker_target_missing_messages(
720
+ workspace: &Path,
721
+ store: &MessageStore,
722
+ event_log: &EventLog,
723
+ recipient: &str,
724
+ owner_team_id: Option<&str>,
725
+ ) -> Result<Vec<String>, MessagingError> {
726
+ let conn = crate::db::schema::open_db(store.db_path())?;
727
+ let now = chrono::Utc::now().to_rfc3339();
728
+ let mut stmt = conn.prepare(
729
+ "select message_id from messages
730
+ where recipient = ?1
731
+ and status = 'queued_pane_missing'
732
+ and error = 'tmux_target_missing'
733
+ and (
734
+ (?2 is null and owner_team_id is null)
735
+ or owner_team_id = ?2
736
+ )
737
+ order by created_at, message_id",
738
+ )?;
739
+ let ids = stmt
740
+ .query_map(params![recipient, owner_team_id], |row| {
741
+ row.get::<_, String>(0)
742
+ })?
743
+ .collect::<Result<Vec<_>, _>>()?;
744
+ drop(stmt);
745
+ for message_id in &ids {
746
+ conn.execute(
747
+ "update messages
748
+ set status = 'accepted',
749
+ error = null,
750
+ updated_at = ?2
751
+ where message_id = ?1",
752
+ params![message_id, now.as_str()],
753
+ )?;
754
+ }
755
+ if !ids.is_empty() {
756
+ event_log.write(
757
+ "worker_receiver.blocked_messages_requeued",
758
+ serde_json::json!({
759
+ "recipient": recipient,
760
+ "owner_team_id": owner_team_id,
761
+ "count": ids.len(),
762
+ "message_ids": ids,
763
+ }),
764
+ )?;
765
+ }
766
+ let _ = workspace;
767
+ Ok(ids)
768
+ }
769
+
577
770
  /// 0.3.27: promoted to pub(crate) for leader_receiver.rs verification gate.
578
771
  pub(crate) fn inject_submit_verified(report: &InjectReport) -> bool {
579
772
  match report.submit_verification {
@@ -1170,34 +1363,17 @@ fn delivery_transport_for_recipient<'a>(
1170
1363
  if socket == crate::tmux_backend::socket_name_for_workspace(workspace) {
1171
1364
  DeliveryTransport::Borrowed(product_transport)
1172
1365
  } else {
1366
+ // 0.5.4 gate054 cross-team leak fix: when `leader_receiver.tmux_socket`
1367
+ // is present and non-canonical, it is a hard channel boundary — the
1368
+ // caller (team A on socket X) explicitly recorded which tmux server
1369
+ // hosts its leader pane. Pane ids (`%N`) are only unique per tmux
1370
+ // server, so probing product/default servers for the same pane id and
1371
+ // switching transports on a match can cross the team/tmux boundary and
1372
+ // inject A's leader traffic into team B's leader pane. If the pane is
1373
+ // absent on the recorded endpoint, let the downstream
1374
+ // `leader_receiver_pane_is_usable` guard produce `rebind_required`
1375
+ // instead of falling back to another server.
1173
1376
  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
1377
  DeliveryTransport::Owned(Box::new(endpoint_backend))
1202
1378
  }
1203
1379
  }
@@ -1237,6 +1413,15 @@ fn leader_receiver_tmux_socket(state: &serde_json::Value) -> Option<&str> {
1237
1413
  leader_receiver_field(state, "tmux_socket")
1238
1414
  }
1239
1415
 
1416
+ /// 0.5.4 gate054 cross-team leak fix: read the bound `leader_receiver.status`
1417
+ /// through the same team-scope resolution as [`leader_receiver_tmux_socket`].
1418
+ /// Restart marks demoted receivers as `"rebind_required"`
1419
+ /// (lifecycle/restart/rebuild.rs:1311); delivery must refuse before injecting
1420
+ /// or cross-server pane-id probing (delivery.rs:1150-1201).
1421
+ fn leader_receiver_status(state: &serde_json::Value) -> Option<&str> {
1422
+ leader_receiver_field(state, "status")
1423
+ }
1424
+
1240
1425
  fn leader_receiver_has_noncanonical_tmux_socket(state: &serde_json::Value) -> bool {
1241
1426
  leader_receiver_tmux_socket(state)
1242
1427
  .is_some_and(|socket| socket != "default" && !std::path::Path::new(socket).is_absolute())
@@ -1494,6 +1679,7 @@ struct PendingMessage {
1494
1679
  task_id: Option<String>,
1495
1680
  owner_team_id: Option<String>,
1496
1681
  status: String,
1682
+ error: Option<String>,
1497
1683
  delivery_attempts: u32,
1498
1684
  }
1499
1685
 
@@ -1504,7 +1690,7 @@ fn message_for_delivery(
1504
1690
  let conn = crate::db::schema::open_db(store.db_path())?;
1505
1691
  let message = conn
1506
1692
  .query_row(
1507
- "select sender, recipient, content, task_id, owner_team_id, status, delivery_attempts from messages where message_id = ?1",
1693
+ "select sender, recipient, content, task_id, owner_team_id, status, error, delivery_attempts from messages where message_id = ?1",
1508
1694
  params![message_id],
1509
1695
  |row| {
1510
1696
  Ok(PendingMessage {
@@ -1514,7 +1700,8 @@ fn message_for_delivery(
1514
1700
  task_id: row.get::<_, Option<String>>(3)?,
1515
1701
  owner_team_id: row.get::<_, Option<String>>(4)?,
1516
1702
  status: row.get::<_, String>(5)?,
1517
- delivery_attempts: row.get::<_, i64>(6)?.max(0) as u32,
1703
+ error: row.get::<_, Option<String>>(6)?,
1704
+ delivery_attempts: row.get::<_, i64>(7)?.max(0) as u32,
1518
1705
  })
1519
1706
  },
1520
1707
  )
@@ -1713,7 +1900,7 @@ pub fn execute_trust_retry(
1713
1900
  }
1714
1901
 
1715
1902
  /// `_record_turn_open_if_leader_to_worker` (`delivery.py:430`):**take-over arm 来自真实投递**
1716
- /// (card §121) —— 仅 leader→worker 注入**成功后**才调 `record_turn_open_after_delivery`,绝不凭空 arm。
1903
+ /// (card §121) —— 仅 leader→worker 注入/投递成功后才 arm,绝不凭空 arm。
1717
1904
  pub fn record_turn_open_if_leader_to_worker(
1718
1905
  workspace: &Path,
1719
1906
  state: &serde_json::Value,
@@ -1728,6 +1915,30 @@ pub fn record_turn_open_if_leader_to_worker(
1728
1915
  )
1729
1916
  }
1730
1917
 
1918
+ fn record_current_turn_after_inject_if_leader_to_worker_scoped(
1919
+ workspace: &Path,
1920
+ sender: &str,
1921
+ recipient: &str,
1922
+ message_id: &str,
1923
+ event_log: &EventLog,
1924
+ owner_team_id: Option<&str>,
1925
+ ) -> Result<(), MessagingError> {
1926
+ if !matches!(sender, "leader" | "Leader") || recipient == "leader" {
1927
+ return Ok(());
1928
+ }
1929
+ let message_id = Some(message_id.to_string());
1930
+ let mut state = scoped_state_for_write(workspace, owner_team_id)?;
1931
+ arm_turn_open(&mut state, recipient, &message_id);
1932
+ save_scoped_state_reapplying_after_conflict(workspace, &state, owner_team_id, |latest| {
1933
+ arm_turn_open(latest, recipient, &message_id);
1934
+ })?;
1935
+ event_log.write(
1936
+ "turn_open.armed_after_inject",
1937
+ serde_json::json!({"agent_id": recipient, "message_id": message_id}),
1938
+ )?;
1939
+ Ok(())
1940
+ }
1941
+
1731
1942
  fn record_turn_open_if_leader_to_worker_scoped(
1732
1943
  workspace: &Path,
1733
1944
  sender: &str,
@@ -1764,6 +1975,19 @@ fn arm_turn_open(state: &mut serde_json::Value, recipient: &str, message_id: &Op
1764
1975
  serde_json::json!({"armed": true, "node_id": recipient, "turn_id": message_id}),
1765
1976
  );
1766
1977
  }
1978
+ if let Some(agent) = root
1979
+ .get_mut("agents")
1980
+ .and_then(serde_json::Value::as_object_mut)
1981
+ .and_then(|agents| agents.get_mut(recipient))
1982
+ .and_then(serde_json::Value::as_object_mut)
1983
+ {
1984
+ agent.insert(
1985
+ "current_task_id".to_string(),
1986
+ message_id.as_ref().map_or(serde_json::Value::Null, |id| {
1987
+ serde_json::Value::String(id.clone())
1988
+ }),
1989
+ );
1990
+ }
1767
1991
  }
1768
1992
 
1769
1993
  /// `_stamp_first_send_at_if_leader_to_worker` (`delivery.py:380`):首次 leader→worker 投递戳
@@ -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()