@team-agent/installer 0.5.10 → 0.5.11

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.10"
578
+ version = "0.5.11"
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.10"
12
+ version = "0.5.11"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -6,6 +6,12 @@ use crate::transport::Transport;
6
6
  pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value, Value) {
7
7
  let mut issues = Vec::new();
8
8
  let mut repairs = Vec::new();
9
+ for issue in crate::topology::diagnose_topology_issues(state, backend) {
10
+ if let Some(id) = crate::topology::issue_id(&issue) {
11
+ repairs.push(topology_repair_hint(id));
12
+ }
13
+ issues.push(issue);
14
+ }
9
15
 
10
16
  if let Some(session_name) = state
11
17
  .get("session_name")
@@ -159,6 +165,18 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
159
165
  (Value::Array(issues), Value::Array(repairs))
160
166
  }
161
167
 
168
+ fn topology_repair_hint(issue: &str) -> Value {
169
+ json!({
170
+ "issue": issue,
171
+ "action_required": true,
172
+ "advisory": true,
173
+ "broken_class": issue,
174
+ "hint_action": "team-agent diagnose --json",
175
+ "dedupe_key": issue,
176
+ "action": "repair the tmux topology mismatch, then rerun team-agent restart",
177
+ })
178
+ }
179
+
162
180
  /// 0.4.x (CR R2): pull (pane_id, provider_label) from leader_receiver. Used
163
181
  /// by `leader_provider_health` reconcile in diagnose. Returns None when the
164
182
  /// leader is not attached or any field is missing.
@@ -3309,12 +3309,21 @@ pub mod lifecycle_port {
3309
3309
  session_name,
3310
3310
  reason,
3311
3311
  error,
3312
+ issue_ids,
3312
3313
  } => json!({
3313
3314
  "ok": false,
3314
3315
  "status": "refused_dirty_topology",
3315
3316
  "reason": reason,
3316
3317
  "session_name": session_name,
3317
3318
  "error": error,
3319
+ "issues": issue_ids
3320
+ .iter()
3321
+ .map(|id| json!({"id": id}))
3322
+ .collect::<Vec<_>>(),
3323
+ "next_actions": [
3324
+ "run team-agent diagnose --json from the intended leader socket",
3325
+ "repair the tmux endpoint/socket split before retrying restart"
3326
+ ],
3318
3327
  "reminder": crate::cli::QUICK_START_REMINDER,
3319
3328
  }),
3320
3329
  }
@@ -721,8 +721,20 @@ fn resolve_leader(
721
721
  let targets = list_targets(transport)?;
722
722
  let matches = targets
723
723
  .iter()
724
- .filter(|target| target.pane_id.as_str() == pane_id)
724
+ .filter(|target| {
725
+ target.pane_id.as_str() == pane_id
726
+ && session.is_none_or(|expected| target.session.as_str() == expected)
727
+ && window.is_none_or(|expected| {
728
+ target
729
+ .window_name
730
+ .as_ref()
731
+ .is_some_and(|name| name.as_str() == expected)
732
+ })
733
+ })
725
734
  .collect::<Vec<_>>();
735
+ let stale_pane_seen = targets
736
+ .iter()
737
+ .any(|target| target.pane_id.as_str() == pane_id);
726
738
  match matches.len() {
727
739
  1 => Ok(ResolvedNamedAddress {
728
740
  raw_name: parsed.display_name(),
@@ -751,6 +763,9 @@ fn resolve_leader(
751
763
  window,
752
764
  socket,
753
765
  );
766
+ if stale_pane_seen {
767
+ err.log = format!("{} stale_tuple_mismatch=true", err.log);
768
+ }
754
769
  err.candidates =
755
770
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
756
771
  Err(err)
@@ -88,6 +88,7 @@ pub mod platform;
88
88
  // 0.3.28 — unified adaptive layout manager (single source of truth for tmux
89
89
  // topology decisions). See `.team/artifacts/adaptive-layout-full-architecture-locate.md`.
90
90
  pub mod layout;
91
+ pub mod topology;
91
92
  pub mod mcp_server;
92
93
  pub mod cli;
93
94
  pub mod packaging;
@@ -127,7 +127,12 @@ pub(crate) fn start_agent_at_paths(
127
127
  // state — assert_topology_invariants from Step 1 catches the
128
128
  // upstream corruption.
129
129
  let has_collision = pane_conflicts_with_leader_or_other(&state, agent_id, &raw_agent);
130
- if has_collision {
130
+ let noop_pane = if adaptive_layout {
131
+ None
132
+ } else {
133
+ single_live_pane_for_window(transport, &session_name, &window)
134
+ };
135
+ if has_collision && noop_pane.is_none() {
131
136
  eprintln!(
132
137
  "team_agent::layout e51_collision_post_step2 agent_id=`{agent_id}` \
133
138
  action=forcing_fresh_spawn \
@@ -135,9 +140,17 @@ pub(crate) fn start_agent_at_paths(
135
140
  investigate upstream state corruption)"
136
141
  );
137
142
  }
138
- let agent_live = agent_live && !has_collision;
143
+ let agent_live = agent_live && (!has_collision || noop_pane.is_some());
139
144
  if !force && agent_live {
140
- mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
145
+ let old_binding = pane_binding_snapshot(&raw_agent);
146
+ let refreshed_binding = noop_pane.as_ref().map(pane_binding_from_live);
147
+ mark_agent_running_noop(
148
+ &mut state,
149
+ agent_id,
150
+ &session_name,
151
+ &window,
152
+ noop_pane.as_ref(),
153
+ )?;
141
154
  let team_key = restart_projection_team_key(&state, team);
142
155
  save_restart_projected_state(workspace, &mut state, &team_key, &[agent_id.as_str()])?;
143
156
  if let Ok(spec) = load_team_spec(spec_workspace) {
@@ -146,6 +159,18 @@ pub(crate) fn start_agent_at_paths(
146
159
  replay_worker_target_missing_messages(workspace, agent_id, &team_key, &state, transport)?;
147
160
  let coordinator_started = start_coordinator_for_workspace(workspace)?;
148
161
  let target = format!("{}:{window}", session_name.as_str());
162
+ if let Some(new_binding) = refreshed_binding.as_ref() {
163
+ if old_binding.as_ref() != Some(new_binding) {
164
+ write_agent_pane_binding_refreshed_event(
165
+ workspace,
166
+ agent_id,
167
+ &session_name,
168
+ &window,
169
+ old_binding.as_ref(),
170
+ new_binding,
171
+ )?;
172
+ }
173
+ }
149
174
  write_start_agent_noop_event(workspace, agent_id, &target, coordinator_started)?;
150
175
  return Ok(StartAgentOutcome::Noop {
151
176
  env: AgentActionEnvelope {
@@ -373,6 +398,78 @@ fn verify_spawned_pane_matches_target(
373
398
  Ok(())
374
399
  }
375
400
 
401
+ #[derive(Debug, Clone, PartialEq, Eq)]
402
+ struct PaneBindingSnapshot {
403
+ pane_id: String,
404
+ pane_pid: Option<u32>,
405
+ }
406
+
407
+ fn pane_binding_snapshot(agent: &serde_json::Value) -> Option<PaneBindingSnapshot> {
408
+ let pane_id = agent
409
+ .get("pane_id")
410
+ .and_then(serde_json::Value::as_str)
411
+ .filter(|pane| !pane.is_empty())?;
412
+ Some(PaneBindingSnapshot {
413
+ pane_id: pane_id.to_string(),
414
+ pane_pid: agent
415
+ .get("pane_pid")
416
+ .and_then(serde_json::Value::as_u64)
417
+ .map(|pid| pid as u32),
418
+ })
419
+ }
420
+
421
+ fn pane_binding_from_live(pane: &crate::transport::PaneInfo) -> PaneBindingSnapshot {
422
+ PaneBindingSnapshot {
423
+ pane_id: pane.pane_id.as_str().to_string(),
424
+ pane_pid: pane.pane_pid,
425
+ }
426
+ }
427
+
428
+ fn single_live_pane_for_window(
429
+ transport: &dyn crate::transport::Transport,
430
+ session: &crate::transport::SessionName,
431
+ window: &str,
432
+ ) -> Option<crate::transport::PaneInfo> {
433
+ let targets = transport.list_targets().ok()?;
434
+ let mut matches = targets.into_iter().filter(|target| {
435
+ target.session.as_str() == session.as_str()
436
+ && target
437
+ .window_name
438
+ .as_ref()
439
+ .is_some_and(|name| name.as_str() == window)
440
+ });
441
+ let pane = matches.next()?;
442
+ if matches.next().is_some() {
443
+ return None;
444
+ }
445
+ Some(pane)
446
+ }
447
+
448
+ fn write_agent_pane_binding_refreshed_event(
449
+ workspace: &Path,
450
+ agent_id: &AgentId,
451
+ session: &crate::transport::SessionName,
452
+ window: &str,
453
+ old: Option<&PaneBindingSnapshot>,
454
+ new: &PaneBindingSnapshot,
455
+ ) -> Result<(), LifecycleError> {
456
+ crate::event_log::EventLog::new(workspace)
457
+ .write(
458
+ "agent_pane_binding_refreshed",
459
+ serde_json::json!({
460
+ "agent_id": agent_id.as_str(),
461
+ "session": session.as_str(),
462
+ "window": window,
463
+ "old_pane_id": old.map(|binding| binding.pane_id.as_str()),
464
+ "old_pane_pid": old.and_then(|binding| binding.pane_pid),
465
+ "pane_id": new.pane_id.as_str(),
466
+ "pane_pid": new.pane_pid,
467
+ }),
468
+ )
469
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
470
+ Ok(())
471
+ }
472
+
376
473
  /// E51 (0.3.26 P0, restart self-heal): returns `true` when the agent's pane_id
377
474
  /// is the same as the leader_receiver/team_owner pane_id OR is owned by a
378
475
  /// different agent in the state. In both cases `start_agent` must NOT treat the
@@ -1284,6 +1284,7 @@ pub(super) fn mark_agent_running_noop(
1284
1284
  agent_id: &AgentId,
1285
1285
  session_name: &SessionName,
1286
1286
  window: &str,
1287
+ pane: Option<&crate::transport::PaneInfo>,
1287
1288
  ) -> Result<(), LifecycleError> {
1288
1289
  if !state.is_object() {
1289
1290
  *state = serde_json::json!({});
@@ -1322,6 +1323,17 @@ pub(super) fn mark_agent_running_noop(
1322
1323
  obj.insert("status".to_string(), serde_json::json!("running"));
1323
1324
  obj.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
1324
1325
  obj.insert("window".to_string(), serde_json::json!(window));
1326
+ if let Some(pane) = pane {
1327
+ obj.insert(
1328
+ "pane_id".to_string(),
1329
+ serde_json::json!(pane.pane_id.as_str()),
1330
+ );
1331
+ if let Some(pane_pid) = pane.pane_pid {
1332
+ obj.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
1333
+ } else {
1334
+ obj.remove("pane_pid");
1335
+ }
1336
+ }
1325
1337
  Ok(())
1326
1338
  }
1327
1339
 
@@ -137,6 +137,32 @@ pub fn restart_with_transport_with_session_convergence_deadline(
137
137
  })?;
138
138
  let mut state = selected.state;
139
139
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state, None)?;
140
+ let topology_issue_ids = crate::topology::restart_dirty_topology_issue_ids(&state);
141
+ if !topology_issue_ids.is_empty() {
142
+ let session_name = state
143
+ .get("session_name")
144
+ .and_then(serde_json::Value::as_str)
145
+ .unwrap_or_default()
146
+ .to_string();
147
+ crate::event_log::EventLog::new(&selected.run_workspace)
148
+ .write(
149
+ "restart.refused_dirty_topology",
150
+ serde_json::json!({
151
+ "session_name": session_name,
152
+ "issues": topology_issue_ids.iter().map(|id| serde_json::json!({"id": id})).collect::<Vec<_>>(),
153
+ }),
154
+ )
155
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
156
+ return Ok(RestartReport::RefusedDirtyTopology {
157
+ session_name,
158
+ reason: topology_issue_ids
159
+ .first()
160
+ .cloned()
161
+ .unwrap_or_else(|| "dirty_topology".to_string()),
162
+ error: "restart refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before restarting".to_string(),
163
+ issue_ids: topology_issue_ids,
164
+ });
165
+ }
140
166
  // E5 task#3 / RC-A6a + E4(leader 裁定:每次 restart 都从角色定义重建 runtime spec,覆盖):
141
167
  // 角色定义=第一真相源。角色齐 → compile_team 重建 + 保留运行期 override(session_name)+
142
168
  // 写 runtime spec。角色缺(TEAM.md/agents 不在)→ 显式拒(列缺哪些),旧 spec 原地保留不删不用。
@@ -278,6 +304,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
278
304
  launcher session ({reason}); aborting before any tmux kill. Repair \
279
305
  state.session_name to the worker session and re-run."
280
306
  ),
307
+ issue_ids: vec![reason.clone()],
281
308
  });
282
309
  }
283
310
  }
@@ -702,6 +702,8 @@ pub enum RestartReport {
702
702
  reason: String,
703
703
  /// Human-readable error directing the user to a recovery action.
704
704
  error: String,
705
+ /// Stable topology issue ids surfaced by CLI/diagnose.
706
+ issue_ids: Vec<String>,
705
707
  },
706
708
  }
707
709
 
@@ -103,6 +103,42 @@ pub fn tmux_pane_width(transport: &dyn Transport, target: &Target) -> PaneWidthQ
103
103
  }
104
104
  }
105
105
 
106
+ pub(crate) fn worker_target_missing_due_to_stale_binding(
107
+ state: &serde_json::Value,
108
+ recipient: &str,
109
+ transport: &dyn Transport,
110
+ ) -> bool {
111
+ let Some(agent) = state.get("agents").and_then(|agents| agents.get(recipient)) else {
112
+ return false;
113
+ };
114
+ let session = state
115
+ .get("session_name")
116
+ .and_then(serde_json::Value::as_str)
117
+ .unwrap_or_default();
118
+ let window = agent
119
+ .get("window")
120
+ .and_then(serde_json::Value::as_str)
121
+ .filter(|s| !s.is_empty())
122
+ .unwrap_or(recipient);
123
+ let Some(cached_pane) = agent
124
+ .get("pane_id")
125
+ .and_then(serde_json::Value::as_str)
126
+ .filter(|s| !s.is_empty())
127
+ else {
128
+ return false;
129
+ };
130
+ let Ok(live_targets) = transport.list_targets() else {
131
+ return false;
132
+ };
133
+ if live_pane_for_session_window(&live_targets, session, window).is_some() {
134
+ return false;
135
+ }
136
+ live_targets.iter().any(|target| {
137
+ target.pane_id.as_str() == cached_pane
138
+ && stale_worker_pane_binding(target, session, window).is_some()
139
+ })
140
+ }
141
+
106
142
  /// `_deliver_pending_message` (`delivery.py:63`):对一条消息做 tmux 注入投递 (含 trust 提示
107
143
  /// 自动应答 + turn-open arm + first_send_at 戳)。daemon-path → Result。
108
144
  pub fn deliver_pending_message(
@@ -323,7 +359,11 @@ pub fn deliver_pending_message(
323
359
  channel: Some("rebind_required".to_string()),
324
360
  });
325
361
  }
326
- let target = resolve_inject_target(state, &message.recipient, transport, &live_targets);
362
+ let resolved = resolve_inject_target(state, &message.recipient, transport, &live_targets);
363
+ if let Some(stale) = resolved.stale_binding.as_ref() {
364
+ event_log.write("worker_pane_binding_stale", stale.as_event_payload(message_id, &message.recipient))?;
365
+ }
366
+ let target = resolved.target.clone();
327
367
  if let Some(outcome) = block_missing_worker_target(
328
368
  store,
329
369
  event_log,
@@ -467,6 +507,7 @@ pub fn deliver_pending_message(
467
507
  message_id,
468
508
  event_log,
469
509
  canonical_owner_team_id.as_deref(),
510
+ resolved.metadata.as_ref(),
470
511
  )?;
471
512
  let submit_verified = inject_submit_verified(&inject_report);
472
513
  let readback_verified = pane_readback_verified(&inject_report);
@@ -554,14 +595,13 @@ pub fn deliver_pending_message(
554
595
  let token_marker = format!("[team-agent-token:{message_id}]");
555
596
  let grace = std::time::Duration::from_millis(200);
556
597
  let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
557
- let mut transcript_has_token = false;
558
- loop {
559
- transcript_has_token = rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
560
- if transcript_has_token || std::time::Instant::now() >= deadline {
561
- break;
598
+ let transcript_has_token = loop {
599
+ let found = rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
600
+ if found || std::time::Instant::now() >= deadline {
601
+ break found;
562
602
  }
563
603
  std::thread::sleep(grace);
564
- }
604
+ };
565
605
  if !transcript_has_token {
566
606
  // Loud but non-fatal: emit a mismatch event for diagnose/status
567
607
  // observability. Do NOT mark delivered — degrade to
@@ -606,10 +646,11 @@ pub fn deliver_pending_message(
606
646
  }
607
647
  }
608
648
  store.mark(message_id, "delivered", None)?;
609
- event_log.write(
610
- "message.delivered",
611
- serde_json::json!({"message_id": message_id}),
612
- )?;
649
+ let mut delivered_event = serde_json::json!({"message_id": message_id});
650
+ if let Some(metadata) = resolved.metadata.as_ref() {
651
+ append_target_metadata(&mut delivered_event, metadata);
652
+ }
653
+ event_log.write("message.delivered", delivered_event)?;
613
654
  let outcome = DeliveryOutcome {
614
655
  ok: true,
615
656
  status: DeliveryStatus::Delivered,
@@ -633,6 +674,7 @@ pub fn deliver_pending_message(
633
674
  &outcome,
634
675
  event_log,
635
676
  canonical_owner_team_id.as_deref(),
677
+ resolved.metadata.as_ref(),
636
678
  )?;
637
679
  Ok(outcome)
638
680
  }
@@ -1056,15 +1098,61 @@ fn app_server_rebind_action(owner_team_id: Option<&str>, receiver: &serde_json::
1056
1098
  /// pane. U1-A real-machine fix is deferred to v0.3.25 (writer-shape + projection +
1057
1099
  /// rediscover-writer triad). U1-B jitter defer at try_deliver_message:213-237 and
1058
1100
  /// U1-C-Tail at the startup-prompt peek site are unchanged.
1101
+ #[derive(Clone)]
1102
+ struct ResolvedInjectTarget {
1103
+ target: Target,
1104
+ metadata: Option<TargetMetadata>,
1105
+ stale_binding: Option<WorkerPaneBindingStale>,
1106
+ }
1107
+
1108
+ #[derive(Clone)]
1109
+ struct TargetMetadata {
1110
+ tmux_endpoint: Option<String>,
1111
+ target_session: String,
1112
+ target_window: String,
1113
+ target_pane_id: String,
1114
+ target_pane_pid: Option<u32>,
1115
+ resolved_from: &'static str,
1116
+ }
1117
+
1118
+ #[derive(Clone)]
1119
+ struct WorkerPaneBindingStale {
1120
+ cached_pane_id: String,
1121
+ expected_session: String,
1122
+ expected_window: String,
1123
+ observed_session: String,
1124
+ observed_window: String,
1125
+ observed_pane_pid: Option<u32>,
1126
+ }
1127
+
1128
+ impl WorkerPaneBindingStale {
1129
+ fn as_event_payload(&self, message_id: &str, recipient: &str) -> serde_json::Value {
1130
+ serde_json::json!({
1131
+ "message_id": message_id,
1132
+ "recipient": recipient,
1133
+ "cached_pane_id": self.cached_pane_id,
1134
+ "expected_session": self.expected_session,
1135
+ "expected_window": self.expected_window,
1136
+ "observed_session": self.observed_session,
1137
+ "observed_window": self.observed_window,
1138
+ "observed_pane_pid": self.observed_pane_pid,
1139
+ })
1140
+ }
1141
+ }
1142
+
1059
1143
  fn resolve_inject_target(
1060
1144
  state: &serde_json::Value,
1061
1145
  recipient: &str,
1062
1146
  transport: &dyn Transport,
1063
1147
  live_targets: &[PaneInfo],
1064
- ) -> Target {
1148
+ ) -> ResolvedInjectTarget {
1065
1149
  if recipient == "leader" {
1066
1150
  if let Some(pane_id) = leader_receiver_pane_id(state) {
1067
- return Target::Pane(PaneId::new(pane_id));
1151
+ return ResolvedInjectTarget {
1152
+ target: Target::Pane(PaneId::new(pane_id)),
1153
+ metadata: None,
1154
+ stale_binding: None,
1155
+ };
1068
1156
  }
1069
1157
  }
1070
1158
  let agent = state.get("agents").and_then(|a| a.get(recipient));
@@ -1096,37 +1184,71 @@ fn resolve_inject_target(
1096
1184
  if leader_receiver_pane_binding(state).is_some_and(|leader| {
1097
1185
  pane_conflicts_on_same_socket(pane.as_str(), worker_socket.as_deref(), leader)
1098
1186
  }) {
1099
- return Target::SessionWindow {
1100
- session: SessionName::new(session),
1101
- window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
1187
+ return ResolvedInjectTarget {
1188
+ target: Target::SessionWindow {
1189
+ session: SessionName::new(session),
1190
+ window: WindowName::new(format!("{recipient}_pane_conflicts_with_leader")),
1191
+ },
1192
+ metadata: None,
1193
+ stale_binding: None,
1102
1194
  };
1103
1195
  }
1104
- if live_targets
1105
- .iter()
1106
- .any(|target| target.pane_id.as_str() == pane.as_str())
1107
- {
1108
- return Target::Pane(pane.clone());
1109
- }
1110
1196
  }
1197
+ let stale_binding = cached_pane.as_ref().and_then(|pane| {
1198
+ live_targets
1199
+ .iter()
1200
+ .find(|target| target.pane_id.as_str() == pane.as_str())
1201
+ .and_then(|target| stale_worker_pane_binding(target, session, window))
1202
+ });
1111
1203
  if let Some(live_pane) = live_pane_for_session_window(live_targets, session, window) {
1112
- return Target::Pane(live_pane);
1204
+ return ResolvedInjectTarget {
1205
+ target: Target::Pane(live_pane.pane_id.clone()),
1206
+ metadata: Some(target_metadata(
1207
+ transport,
1208
+ live_pane,
1209
+ "session_window_lookup",
1210
+ )),
1211
+ stale_binding,
1212
+ };
1113
1213
  }
1114
1214
  if let Some(pane) = cached_pane {
1115
- if live_targets.is_empty() && !cached_pane_known_dead(transport, &pane) {
1116
- return Target::Pane(pane);
1215
+ if let Some(live_pane) = live_targets.iter().find(|target| {
1216
+ target.pane_id.as_str() == pane.as_str()
1217
+ && target.session.as_str() == session
1218
+ && target
1219
+ .window_name
1220
+ .as_ref()
1221
+ .is_some_and(|name| name.as_str() == window)
1222
+ }) {
1223
+ return ResolvedInjectTarget {
1224
+ target: Target::Pane(pane),
1225
+ metadata: Some(target_metadata(transport, live_pane, "validated_cached_pane")),
1226
+ stale_binding: None,
1227
+ };
1228
+ }
1229
+ if live_targets.is_empty() {
1230
+ return ResolvedInjectTarget {
1231
+ target: Target::Pane(pane),
1232
+ metadata: None,
1233
+ stale_binding: None,
1234
+ };
1117
1235
  }
1118
1236
  }
1119
- Target::SessionWindow {
1120
- session: SessionName::new(session),
1121
- window: WindowName::new(window),
1237
+ ResolvedInjectTarget {
1238
+ target: Target::SessionWindow {
1239
+ session: SessionName::new(session),
1240
+ window: WindowName::new(window),
1241
+ },
1242
+ metadata: None,
1243
+ stale_binding,
1122
1244
  }
1123
1245
  }
1124
1246
 
1125
- fn live_pane_for_session_window(
1126
- targets: &[PaneInfo],
1247
+ fn live_pane_for_session_window<'a>(
1248
+ targets: &'a [PaneInfo],
1127
1249
  session: &str,
1128
1250
  window: &str,
1129
- ) -> Option<PaneId> {
1251
+ ) -> Option<&'a PaneInfo> {
1130
1252
  targets
1131
1253
  .iter()
1132
1254
  .find(|target| {
@@ -1136,14 +1258,86 @@ fn live_pane_for_session_window(
1136
1258
  .as_ref()
1137
1259
  .is_some_and(|name| name.as_str() == window)
1138
1260
  })
1139
- .map(|target| target.pane_id.clone())
1140
1261
  }
1141
1262
 
1142
- fn cached_pane_known_dead(transport: &dyn Transport, pane: &PaneId) -> bool {
1143
- if matches!(transport.has_pane(pane), Ok(Some(false))) {
1144
- return true;
1263
+ fn stale_worker_pane_binding(
1264
+ observed: &PaneInfo,
1265
+ expected_session: &str,
1266
+ expected_window: &str,
1267
+ ) -> Option<WorkerPaneBindingStale> {
1268
+ let observed_window = observed
1269
+ .window_name
1270
+ .as_ref()
1271
+ .map(|window| window.as_str())
1272
+ .unwrap_or_default();
1273
+ if observed.session.as_str() == expected_session && observed_window == expected_window {
1274
+ return None;
1275
+ }
1276
+ Some(WorkerPaneBindingStale {
1277
+ cached_pane_id: observed.pane_id.as_str().to_string(),
1278
+ expected_session: expected_session.to_string(),
1279
+ expected_window: expected_window.to_string(),
1280
+ observed_session: observed.session.as_str().to_string(),
1281
+ observed_window: observed_window.to_string(),
1282
+ observed_pane_pid: observed.pane_pid,
1283
+ })
1284
+ }
1285
+
1286
+ fn target_metadata(
1287
+ transport: &dyn Transport,
1288
+ pane: &PaneInfo,
1289
+ resolved_from: &'static str,
1290
+ ) -> TargetMetadata {
1291
+ TargetMetadata {
1292
+ tmux_endpoint: transport.tmux_endpoint(),
1293
+ target_session: pane.session.as_str().to_string(),
1294
+ target_window: pane
1295
+ .window_name
1296
+ .as_ref()
1297
+ .map(|window| window.as_str().to_string())
1298
+ .unwrap_or_default(),
1299
+ target_pane_id: pane.pane_id.as_str().to_string(),
1300
+ target_pane_pid: pane.pane_pid,
1301
+ resolved_from,
1145
1302
  }
1146
- matches!(transport.liveness(pane), Ok(PaneLiveness::Dead))
1303
+ }
1304
+
1305
+ fn append_target_metadata(event: &mut serde_json::Value, metadata: &TargetMetadata) {
1306
+ let Some(obj) = event.as_object_mut() else {
1307
+ return;
1308
+ };
1309
+ obj.insert("target_kind".to_string(), serde_json::json!("pane"));
1310
+ obj.insert(
1311
+ "tmux_endpoint".to_string(),
1312
+ metadata
1313
+ .tmux_endpoint
1314
+ .as_ref()
1315
+ .map(|endpoint| serde_json::json!(endpoint))
1316
+ .unwrap_or(serde_json::Value::Null),
1317
+ );
1318
+ obj.insert(
1319
+ "target_session".to_string(),
1320
+ serde_json::json!(metadata.target_session),
1321
+ );
1322
+ obj.insert(
1323
+ "target_window".to_string(),
1324
+ serde_json::json!(metadata.target_window),
1325
+ );
1326
+ obj.insert(
1327
+ "target_pane_id".to_string(),
1328
+ serde_json::json!(metadata.target_pane_id),
1329
+ );
1330
+ obj.insert(
1331
+ "target_pane_pid".to_string(),
1332
+ metadata
1333
+ .target_pane_pid
1334
+ .map(|pid| serde_json::json!(pid))
1335
+ .unwrap_or(serde_json::Value::Null),
1336
+ );
1337
+ obj.insert(
1338
+ "resolved_from".to_string(),
1339
+ serde_json::json!(metadata.resolved_from),
1340
+ );
1147
1341
  }
1148
1342
 
1149
1343
  #[derive(Clone, Copy)]
@@ -1911,7 +2105,7 @@ pub fn record_turn_open_if_leader_to_worker(
1911
2105
  ) -> Result<(), MessagingError> {
1912
2106
  let _ = state;
1913
2107
  record_turn_open_if_leader_to_worker_scoped(
1914
- workspace, sender, recipient, delivered, event_log, None,
2108
+ workspace, sender, recipient, delivered, event_log, None, None,
1915
2109
  )
1916
2110
  }
1917
2111
 
@@ -1922,6 +2116,7 @@ fn record_current_turn_after_inject_if_leader_to_worker_scoped(
1922
2116
  message_id: &str,
1923
2117
  event_log: &EventLog,
1924
2118
  owner_team_id: Option<&str>,
2119
+ metadata: Option<&TargetMetadata>,
1925
2120
  ) -> Result<(), MessagingError> {
1926
2121
  if !matches!(sender, "leader" | "Leader") || recipient == "leader" {
1927
2122
  return Ok(());
@@ -1932,10 +2127,11 @@ fn record_current_turn_after_inject_if_leader_to_worker_scoped(
1932
2127
  save_scoped_state_reapplying_after_conflict(workspace, &state, owner_team_id, |latest| {
1933
2128
  arm_turn_open(latest, recipient, &message_id);
1934
2129
  })?;
1935
- event_log.write(
1936
- "turn_open.armed_after_inject",
1937
- serde_json::json!({"agent_id": recipient, "message_id": message_id}),
1938
- )?;
2130
+ let mut event = serde_json::json!({"agent_id": recipient, "message_id": message_id});
2131
+ if let Some(metadata) = metadata {
2132
+ append_target_metadata(&mut event, metadata);
2133
+ }
2134
+ event_log.write("turn_open.armed_after_inject", event)?;
1939
2135
  Ok(())
1940
2136
  }
1941
2137
 
@@ -1946,6 +2142,7 @@ fn record_turn_open_if_leader_to_worker_scoped(
1946
2142
  delivered: &DeliveryOutcome,
1947
2143
  event_log: &EventLog,
1948
2144
  owner_team_id: Option<&str>,
2145
+ metadata: Option<&TargetMetadata>,
1949
2146
  ) -> Result<(), MessagingError> {
1950
2147
  if !delivered.ok || !matches!(sender, "leader" | "Leader") || recipient == "leader" {
1951
2148
  return Ok(());
@@ -1955,10 +2152,12 @@ fn record_turn_open_if_leader_to_worker_scoped(
1955
2152
  save_scoped_state_reapplying_after_conflict(workspace, &state, owner_team_id, |latest| {
1956
2153
  arm_turn_open(latest, recipient, &delivered.message_id);
1957
2154
  })?;
1958
- event_log.write(
1959
- "turn_open.armed_after_delivery",
1960
- serde_json::json!({"agent_id": recipient, "message_id": delivered.message_id}),
1961
- )?;
2155
+ let mut event =
2156
+ serde_json::json!({"agent_id": recipient, "message_id": delivered.message_id});
2157
+ if let Some(metadata) = metadata {
2158
+ append_target_metadata(&mut event, metadata);
2159
+ }
2160
+ event_log.write("turn_open.armed_after_delivery", event)?;
1962
2161
  Ok(())
1963
2162
  }
1964
2163
 
@@ -10,7 +10,7 @@ use crate::transport::{PaneId, Transport};
10
10
 
11
11
  use super::helpers::{status_wire, MessageStatusShadow};
12
12
  use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_message_id};
13
- use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStatus, MessagingError};
13
+ use super::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessagingError};
14
14
 
15
15
  /// 发件目标:单 target / 广播 `*` / 扇出 list (`send.py:36` `target: str|list[str]|None`)。
16
16
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -182,6 +182,9 @@ pub fn send_message(
182
182
  }
183
183
  }
184
184
  }
185
+ if let Some(outcome) = stale_worker_target_preflight(workspace, &state, recipient, opts)? {
186
+ return Ok(outcome);
187
+ }
185
188
  if let Some(outcome) = coordinator_unavailable_outcome(workspace, recipient, opts, &event_log)?
186
189
  {
187
190
  return Ok(outcome);
@@ -231,6 +234,38 @@ pub fn send_message(
231
234
  })
232
235
  }
233
236
 
237
+ fn stale_worker_target_preflight(
238
+ workspace: &Path,
239
+ state: &serde_json::Value,
240
+ recipient: &str,
241
+ opts: &SendOptions,
242
+ ) -> Result<Option<DeliveryOutcome>, MessagingError> {
243
+ let Ok(resolved) = crate::transport_factory::resolve_read_only_transport(
244
+ workspace,
245
+ Some(state),
246
+ crate::transport_factory::TransportPurpose::MessageDelivery,
247
+ ) else {
248
+ return Ok(None);
249
+ };
250
+ if !super::delivery::worker_target_missing_due_to_stale_binding(
251
+ state,
252
+ recipient,
253
+ resolved.backend.as_ref(),
254
+ ) {
255
+ return Ok(None);
256
+ }
257
+ Ok(Some(DeliveryOutcome {
258
+ ok: false,
259
+ status: DeliveryStatus::Blocked,
260
+ message_status: MessageStatusShadow("queued_pane_missing".to_string()),
261
+ message_id: opts.message_id.clone(),
262
+ verification: Some(format!("run team-agent start-agent {recipient} --allow-fresh")),
263
+ stage: Some(DeliveryStage::Inject),
264
+ reason: Some(DeliveryRefusal::TmuxTargetMissing),
265
+ channel: Some("delivery_blocked".to_string()),
266
+ }))
267
+ }
268
+
234
269
  fn task_exists(state: &serde_json::Value, task_id: &TaskId) -> bool {
235
270
  state
236
271
  .get("tasks")
@@ -0,0 +1,152 @@
1
+ use serde_json::{json, Value};
2
+
3
+ use crate::transport::{SessionName, Transport};
4
+
5
+ pub const WORKER_PANE_BINDING_STALE: &str = "worker_pane_binding_stale";
6
+ pub const TMUX_ENDPOINT_SOCKET_CONFLICT: &str = "tmux_endpoint_socket_conflict";
7
+ pub const LEADER_RECEIVER_SOCKET_MISMATCH: &str = "leader_receiver_socket_mismatch";
8
+ pub const ORPHAN_TEAM_SESSION_ON_IGNORED_SOCKET: &str = "orphan_team_session_on_ignored_socket";
9
+ pub const TEAM_SESSION_MISSING_ON_CANONICAL_SOCKET: &str =
10
+ "team_session_missing_on_canonical_socket";
11
+ pub const RECENT_COORDINATOR_SESSION_MISSING: &str = "recent_coordinator_session_missing";
12
+
13
+ pub fn diagnose_topology_issues(state: &Value, backend: &dyn Transport) -> Vec<Value> {
14
+ let mut issues = Vec::new();
15
+ append_socket_split_issues(state, &mut issues, true);
16
+ append_worker_pane_binding_issues(state, backend, &mut issues);
17
+ issues
18
+ }
19
+
20
+ pub fn restart_dirty_topology_issue_ids(state: &Value) -> Vec<String> {
21
+ let mut issues = Vec::new();
22
+ append_socket_split_issues(state, &mut issues, false);
23
+ issues
24
+ .into_iter()
25
+ .filter_map(|issue| issue.get("id").and_then(Value::as_str).map(str::to_string))
26
+ .collect()
27
+ }
28
+
29
+ pub fn issue_id(issue: &Value) -> Option<&str> {
30
+ issue.get("id").and_then(Value::as_str)
31
+ }
32
+
33
+ fn append_socket_split_issues(state: &Value, issues: &mut Vec<Value>, include_readiness: bool) {
34
+ let endpoint = non_empty_str(state, "tmux_endpoint");
35
+ let socket = non_empty_str(state, "tmux_socket");
36
+ let (Some(endpoint), Some(socket)) = (endpoint, socket) else {
37
+ return;
38
+ };
39
+ if same_endpoint(endpoint, socket) {
40
+ return;
41
+ }
42
+
43
+ let session = non_empty_str(state, "session_name").unwrap_or_default();
44
+ issues.push(json!({
45
+ "id": TMUX_ENDPOINT_SOCKET_CONFLICT,
46
+ "tmux_endpoint": endpoint,
47
+ "tmux_socket": socket,
48
+ }));
49
+
50
+ if state
51
+ .get("leader_receiver")
52
+ .and_then(|receiver| non_empty_str(receiver, "tmux_socket"))
53
+ .is_some_and(|leader_socket| !same_endpoint(leader_socket, endpoint))
54
+ {
55
+ issues.push(json!({
56
+ "id": LEADER_RECEIVER_SOCKET_MISMATCH,
57
+ "tmux_endpoint": endpoint,
58
+ "leader_receiver_tmux_socket": state
59
+ .get("leader_receiver")
60
+ .and_then(|receiver| non_empty_str(receiver, "tmux_socket")),
61
+ }));
62
+ }
63
+
64
+ if !session.is_empty() && session_exists_on_endpoint(endpoint, session) {
65
+ issues.push(json!({
66
+ "id": ORPHAN_TEAM_SESSION_ON_IGNORED_SOCKET,
67
+ "ignored_tmux_endpoint": endpoint,
68
+ "session_name": session,
69
+ }));
70
+ }
71
+
72
+ if include_readiness && !session.is_empty() && !session_exists_on_endpoint(socket, session) {
73
+ issues.push(json!({
74
+ "id": TEAM_SESSION_MISSING_ON_CANONICAL_SOCKET,
75
+ "tmux_endpoint": socket,
76
+ "session_name": session,
77
+ }));
78
+ issues.push(json!({
79
+ "id": RECENT_COORDINATOR_SESSION_MISSING,
80
+ "tmux_endpoint": socket,
81
+ "session_name": session,
82
+ }));
83
+ }
84
+ }
85
+
86
+ fn append_worker_pane_binding_issues(
87
+ state: &Value,
88
+ backend: &dyn Transport,
89
+ issues: &mut Vec<Value>,
90
+ ) {
91
+ let session = non_empty_str(state, "session_name").unwrap_or_default();
92
+ let Some(agents) = state.get("agents").and_then(Value::as_object) else {
93
+ return;
94
+ };
95
+ let Ok(live_targets) = backend.list_targets() else {
96
+ return;
97
+ };
98
+ for (agent_id, agent) in agents {
99
+ let Some(cached_pane_id) = non_empty_str(agent, "pane_id") else {
100
+ continue;
101
+ };
102
+ let window = non_empty_str(agent, "window").unwrap_or(agent_id.as_str());
103
+ let Some(observed) = live_targets
104
+ .iter()
105
+ .find(|pane| pane.pane_id.as_str() == cached_pane_id)
106
+ else {
107
+ continue;
108
+ };
109
+ let observed_window = observed
110
+ .window_name
111
+ .as_ref()
112
+ .map(|window| window.as_str())
113
+ .unwrap_or_default();
114
+ if observed.session.as_str() == session && observed_window == window {
115
+ continue;
116
+ }
117
+ issues.push(json!({
118
+ "id": WORKER_PANE_BINDING_STALE,
119
+ "agent_id": agent_id,
120
+ "cached_pane_id": cached_pane_id,
121
+ "expected_session": session,
122
+ "expected_window": window,
123
+ "observed_session": observed.session.as_str(),
124
+ "observed_window": observed_window,
125
+ "observed_pane_pid": observed.pane_pid,
126
+ }));
127
+ }
128
+ }
129
+
130
+ fn session_exists_on_endpoint(endpoint: &str, session: &str) -> bool {
131
+ crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
132
+ .has_session(&SessionName::new(session.to_string()))
133
+ .unwrap_or(false)
134
+ }
135
+
136
+ fn same_endpoint(left: &str, right: &str) -> bool {
137
+ normalize_endpoint(left) == normalize_endpoint(right)
138
+ }
139
+
140
+ fn normalize_endpoint(value: &str) -> String {
141
+ if std::path::Path::new(value).is_absolute() {
142
+ value.to_string()
143
+ } else if let Some(path) = crate::tmux_backend::socket_path_for_name(value) {
144
+ path.to_string_lossy().into_owned()
145
+ } else {
146
+ value.to_string()
147
+ }
148
+ }
149
+
150
+ fn non_empty_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
151
+ value.get(key).and_then(Value::as_str).filter(|s| !s.is_empty())
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
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.10",
24
- "@team-agent/cli-darwin-x64": "0.5.10",
25
- "@team-agent/cli-linux-x64": "0.5.10"
23
+ "@team-agent/cli-darwin-arm64": "0.5.11",
24
+ "@team-agent/cli-darwin-x64": "0.5.11",
25
+ "@team-agent/cli-linux-x64": "0.5.11"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",