@team-agent/installer 0.5.3 → 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.
Files changed (31) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +9 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +191 -31
  5. package/crates/team-agent/src/cli/mod.rs +93 -34
  6. package/crates/team-agent/src/cli/send.rs +69 -2
  7. package/crates/team-agent/src/cli/status_port.rs +62 -0
  8. package/crates/team-agent/src/cli/tests/status_send.rs +12 -19
  9. package/crates/team-agent/src/coordinator/tick.rs +13 -0
  10. package/crates/team-agent/src/lifecycle/launch.rs +14 -6
  11. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -1
  12. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -2
  13. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +108 -6
  14. package/crates/team-agent/src/lifecycle/restart/selection.rs +47 -18
  15. package/crates/team-agent/src/lifecycle/restart.rs +16 -6
  16. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +287 -53
  17. package/crates/team-agent/src/lifecycle/tests/restart.rs +144 -5
  18. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  19. package/crates/team-agent/src/messaging/delivery.rs +209 -30
  20. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -25
  21. package/crates/team-agent/src/messaging/tests/runtime.rs +446 -0
  22. package/crates/team-agent/src/messaging/watchers.rs +36 -19
  23. package/crates/team-agent/src/provider/adapter.rs +103 -41
  24. package/crates/team-agent/src/provider/session/capture.rs +328 -66
  25. package/crates/team-agent/src/provider/session/resume.rs +30 -28
  26. package/crates/team-agent/src/provider/session_scan/common.rs +117 -1
  27. package/crates/team-agent/src/provider/session_scan/copilot.rs +1 -0
  28. package/crates/team-agent/src/provider/session_scan.rs +1 -0
  29. package/crates/team-agent/src/state/persist.rs +17 -0
  30. package/crates/team-agent/src/state/projection.rs +59 -4
  31. package/package.json +4 -4
@@ -101,11 +101,8 @@ fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
101
101
  use std::sync::atomic::{AtomicU32, Ordering};
102
102
  static N: AtomicU32 = AtomicU32::new(0);
103
103
  let n = N.fetch_add(1, Ordering::Relaxed);
104
- let ws = std::env::temp_dir().join(format!(
105
- "ta_rs_l2_backingmiss_{}_{}",
106
- std::process::id(),
107
- n
108
- ));
104
+ let ws =
105
+ std::env::temp_dir().join(format!("ta_rs_l2_backingmiss_{}_{}", std::process::id(), n));
109
106
  std::fs::create_dir_all(&ws).unwrap();
110
107
 
111
108
  let missing_rollout = ws.join(".missing-rollout.jsonl");
@@ -168,6 +165,148 @@ fn layer2_backing_missing_refusal_carries_checked_paths_and_recovery_hint() {
168
165
  }
169
166
  }
170
167
 
168
+ fn write_codex_identity_rollout(
169
+ workspace: &std::path::Path,
170
+ file_name: &str,
171
+ session_id: &str,
172
+ embedded_agent_id: &str,
173
+ ) -> std::path::PathBuf {
174
+ let path = workspace.join(file_name);
175
+ let text = format!(
176
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
177
+ {{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
178
+ {{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
179
+ workspace.to_string_lossy()
180
+ );
181
+ std::fs::write(&path, text).unwrap();
182
+ path
183
+ }
184
+
185
+ #[test]
186
+ fn restart_refuses_codex_session_identity_mismatch_without_allow_fresh() {
187
+ let ws = temp_ws();
188
+ let rollout = write_codex_identity_rollout(
189
+ &ws,
190
+ "rollout-frontend-poison.jsonl",
191
+ "019f3327-c35a-7023-b3cd-1bea93a7a157",
192
+ "ios-dev",
193
+ );
194
+ let state = json!({
195
+ "agents": {
196
+ "frontend": {
197
+ "provider": "codex",
198
+ "status": "running",
199
+ "session_id": "019f3327-c35a-7023-b3cd-1bea93a7a157",
200
+ "rollout_path": rollout.to_string_lossy(),
201
+ "captured_at": "2026-07-05T17:04:04Z",
202
+ "captured_via": "fs_watch",
203
+ "spawn_cwd": ws.to_string_lossy(),
204
+ "first_send_at": "2026-07-05T17:10:00Z"
205
+ }
206
+ }
207
+ });
208
+
209
+ let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
210
+ Some(&ws),
211
+ &state,
212
+ false,
213
+ )
214
+ .unwrap();
215
+
216
+ assert_eq!(plan.decisions.len(), 1);
217
+ assert_eq!(plan.decisions[0].decision, ResumeDecision::Refuse);
218
+ assert_eq!(plan.unresumable.len(), 1);
219
+ let entry = &plan.unresumable[0];
220
+ assert_eq!(entry.agent_id.as_str(), "frontend");
221
+ assert_eq!(entry.reason, "session_identity_mismatch");
222
+ assert_eq!(
223
+ entry
224
+ .refusal_reason
225
+ .as_ref()
226
+ .map(crate::provider::session::ResumeRefusalReason::wire),
227
+ Some("session_identity_mismatch")
228
+ );
229
+ }
230
+
231
+ #[test]
232
+ fn restart_identity_probe_reports_real_frontend_ios_dev_mismatch_shape() {
233
+ let ws = temp_ws();
234
+ let rollout = write_codex_identity_rollout(
235
+ &ws,
236
+ "rollout-frontend-poison.jsonl",
237
+ "019f3327-c35a-7023-b3cd-1bea93a7a157",
238
+ "ios-dev",
239
+ );
240
+ let rollout_path = crate::provider::RolloutPath::new(rollout.clone());
241
+ let probe = crate::lifecycle::restart::session_identity_probe_for_agent(
242
+ &aid("frontend"),
243
+ crate::provider::Provider::Codex,
244
+ Some(&rollout_path),
245
+ );
246
+ assert_eq!(probe.identity_ok, Some(false));
247
+ assert_eq!(probe.embedded_agent_id.as_deref(), Some("ios-dev"));
248
+ assert_eq!(probe.rollout_path.as_deref(), Some(rollout.as_path()));
249
+ }
250
+
251
+ #[test]
252
+ fn restart_allow_fresh_only_fresh_starts_mismatched_codex_worker() {
253
+ let ws = temp_ws();
254
+ let frontend_rollout = write_codex_identity_rollout(
255
+ &ws,
256
+ "rollout-frontend-poison.jsonl",
257
+ "019f3327-c35a-7023-b3cd-1bea93a7a157",
258
+ "ios-dev",
259
+ );
260
+ let backend_rollout = write_codex_identity_rollout(
261
+ &ws,
262
+ "rollout-backend-valid.jsonl",
263
+ "019f3327-backend-valid",
264
+ "backend",
265
+ );
266
+ let state = json!({
267
+ "agents": {
268
+ "frontend": {
269
+ "provider": "codex",
270
+ "status": "running",
271
+ "session_id": "019f3327-c35a-7023-b3cd-1bea93a7a157",
272
+ "rollout_path": frontend_rollout.to_string_lossy(),
273
+ "captured_at": "2026-07-05T17:04:04Z",
274
+ "captured_via": "fs_watch",
275
+ "spawn_cwd": ws.to_string_lossy()
276
+ },
277
+ "backend": {
278
+ "provider": "codex",
279
+ "status": "running",
280
+ "session_id": "019f3327-backend-valid",
281
+ "rollout_path": backend_rollout.to_string_lossy(),
282
+ "captured_at": "2026-07-05T17:04:05Z",
283
+ "captured_via": "fs_watch",
284
+ "spawn_cwd": ws.to_string_lossy()
285
+ }
286
+ }
287
+ });
288
+
289
+ let plan = crate::lifecycle::restart::classify_restart_plan_with_resume_validation(
290
+ Some(&ws),
291
+ &state,
292
+ true,
293
+ )
294
+ .unwrap();
295
+
296
+ assert!(
297
+ plan.unresumable.is_empty(),
298
+ "allow_fresh should convert the poisoned worker to fresh without refusing; got {:?}",
299
+ plan.unresumable
300
+ );
301
+ let decisions = plan
302
+ .decisions
303
+ .iter()
304
+ .map(|decision| (decision.agent_id.as_str(), decision.decision))
305
+ .collect::<std::collections::BTreeMap<_, _>>();
306
+ assert_eq!(decisions.get("frontend"), Some(&ResumeDecision::FreshStart));
307
+ assert_eq!(decisions.get("backend"), Some(&ResumeDecision::Resume));
308
+ }
309
+
171
310
  #[test]
172
311
  fn unit0_restart_corrupt_first_send_at_blocks_before_resume_classification() {
173
312
  // The corrupt-first_send_at branch is the hard-refuse gate that fires
@@ -440,6 +440,7 @@ pub struct StartedAgent {
440
440
  pub agent_id: AgentId,
441
441
  pub start_mode: StartMode,
442
442
  pub target: String,
443
+ pub spawned_at: String,
443
444
  pub session_id: Option<SessionId>,
444
445
  pub rollout_path: Option<RolloutPath>,
445
446
  pub pending_session_id: Option<SessionId>,
@@ -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()