@team-agent/installer 0.5.9 → 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.9"
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.9"
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
  }
@@ -267,6 +267,26 @@ pub(crate) fn resolve_name_for_cli(
267
267
  Ok((resolved, transport))
268
268
  }
269
269
 
270
+ /// E6 wiring helper (`cli::send::maybe_enqueue_offline_leader_mailbox`):
271
+ /// re-parse a `<workspace>::<team>/leader` name and return the resolved
272
+ /// target workspace + canonical team_key so send.rs can enqueue the
273
+ /// mailbox without duplicating the parser. Returns `Ok(None)` for names
274
+ /// that aren't `<team>/leader` shapes (worker sends, session:window,
275
+ /// etc. keep their existing refusal semantics).
276
+ pub(crate) fn parse_leader_target_workspace_and_team(
277
+ sender_workspace: &Path,
278
+ raw_name: &str,
279
+ ) -> Result<Option<(PathBuf, String)>, NamedAddressError> {
280
+ let parsed = parse_named_address(raw_name)?;
281
+ let target_workspace = resolve_workspace(sender_workspace, parsed.workspace.as_deref())?;
282
+ match parsed.target {
283
+ ParsedTarget::TeamEntity { team, entity } if entity == "leader" => {
284
+ Ok(Some((target_workspace, team)))
285
+ }
286
+ _ => Ok(None),
287
+ }
288
+ }
289
+
270
290
  #[derive(Debug, Clone, PartialEq, Eq)]
271
291
  struct ParsedNamedAddress {
272
292
  workspace: Option<PathBuf>,
@@ -701,8 +721,20 @@ fn resolve_leader(
701
721
  let targets = list_targets(transport)?;
702
722
  let matches = targets
703
723
  .iter()
704
- .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
+ })
705
734
  .collect::<Vec<_>>();
735
+ let stale_pane_seen = targets
736
+ .iter()
737
+ .any(|target| target.pane_id.as_str() == pane_id);
706
738
  match matches.len() {
707
739
  1 => Ok(ResolvedNamedAddress {
708
740
  raw_name: parsed.display_name(),
@@ -731,6 +763,9 @@ fn resolve_leader(
731
763
  window,
732
764
  socket,
733
765
  );
766
+ if stale_pane_seen {
767
+ err.log = format!("{} stale_tuple_mismatch=true", err.log);
768
+ }
734
769
  err.candidates =
735
770
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
736
771
  Err(err)
@@ -52,10 +52,34 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
52
52
  let (resolved, transport) =
53
53
  match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
54
54
  Ok(resolved) => resolved,
55
- Err(error) if args.json => {
56
- return Ok(CmdResult::from_json(error.to_json(), args.json));
55
+ Err(error) => {
56
+ // E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
57
+ // the resolver refuses with `leader_not_attached`, the team
58
+ // itself may still be alive (worker + coordinator running
59
+ // without a bound leader). Third-party senders in that
60
+ // shape must land in the offline mailbox — same
61
+ // canonical team.db row + queued_until_leader_attach
62
+ // status the coordinator/attach hook replay through the
63
+ // existing pipeline exactly once. Owner-scope refusals
64
+ // (same workspace as target) keep the actionable attach
65
+ // hint — E6 owner copy is documented as
66
+ // `run team-agent attach-leader`.
67
+ if let Some(mut value) = maybe_enqueue_offline_leader_mailbox(
68
+ &args.workspace,
69
+ to_name,
70
+ &content,
71
+ &args.sender,
72
+ args.task.as_deref(),
73
+ &error,
74
+ )? {
75
+ add_send_reminder_if_ok(&mut value);
76
+ return Ok(cmd_send_result(value, args.json));
77
+ }
78
+ if args.json {
79
+ return Ok(CmdResult::from_json(error.to_json(), args.json));
80
+ }
81
+ return Err(CliError::Usage(error.n38_message()));
57
82
  }
58
- Err(error) => return Err(CliError::Usage(error.n38_message())),
59
83
  };
60
84
  let mut value = send_to_named_pane_direct(
61
85
  &args.workspace,
@@ -786,6 +810,119 @@ fn add_send_reminder_if_ok(value: &mut Value) {
786
810
  }
787
811
  }
788
812
 
813
+ /// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
814
+ /// escape evidence
815
+ /// `.team/artifacts/0.5.9-subscription-gate.md` +
816
+ /// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
817
+ /// when the `--to-name <ws>::<team>/leader` resolver refused with
818
+ /// `leader_not_attached`, decide whether the target team is still alive
819
+ /// (worker + coordinator running without a bound leader) and, if so,
820
+ /// enqueue the mailbox row so `attach-leader` replays it exactly once.
821
+ ///
822
+ /// Only queues for third-party senders (sender workspace ≠ target
823
+ /// workspace). Owner-scope refusals stay refused so status/diagnose can
824
+ /// keep pushing the operator toward `attach-leader`.
825
+ fn maybe_enqueue_offline_leader_mailbox(
826
+ sender_workspace: &Path,
827
+ to_name: &str,
828
+ content: &str,
829
+ sender: &str,
830
+ task_id: Option<&str>,
831
+ error: &crate::cli::named_address::NamedAddressError,
832
+ ) -> Result<Option<Value>, CliError> {
833
+ if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
834
+ return Ok(None);
835
+ }
836
+ let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
837
+ sender_workspace,
838
+ to_name,
839
+ ) {
840
+ Ok(Some(v)) => v,
841
+ Ok(None) => return Ok(None),
842
+ Err(_) => return Ok(None),
843
+ };
844
+ let (target_workspace, team_key) = parsed;
845
+ // Owner-scope refusal: sender workspace == target workspace. Keep
846
+ // the actionable attach hint (owner sees status/diagnose copy that
847
+ // points at `attach-leader`).
848
+ let sender_canonical = std::fs::canonicalize(sender_workspace)
849
+ .unwrap_or_else(|_| sender_workspace.to_path_buf());
850
+ let target_canonical = std::fs::canonicalize(&target_workspace)
851
+ .unwrap_or_else(|_| target_workspace.clone());
852
+ if sender_canonical == target_canonical {
853
+ return Ok(None);
854
+ }
855
+ // Verify the target team is actually alive on this host — mailbox
856
+ // is only for `team live + leader unattached`. Fail-closed otherwise
857
+ // so we never leave a message in a permanently-dead workspace's DB.
858
+ let state = match crate::state::persist::load_runtime_state(&target_workspace) {
859
+ Ok(s) => s,
860
+ Err(_) => return Ok(None),
861
+ };
862
+ let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
863
+ if !team_alive {
864
+ return Ok(None);
865
+ }
866
+ let event_log = crate::event_log::EventLog::new(&target_workspace);
867
+ let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
868
+ let outcome = messaging::enqueue_leader_mailbox_until_attach(
869
+ &target_workspace,
870
+ &team_key,
871
+ content,
872
+ task.as_ref(),
873
+ sender,
874
+ &event_log,
875
+ )
876
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
877
+ let message_id = outcome
878
+ .message_id
879
+ .clone()
880
+ .unwrap_or_else(|| "".to_string());
881
+ Ok(Some(json!({
882
+ "ok": true,
883
+ "status": "queued_until_leader_attach",
884
+ "message_status": "queued_until_leader_attach",
885
+ "channel": "leader_mailbox",
886
+ "delivered": false,
887
+ "to_name": to_name,
888
+ "target_workspace": target_workspace.display().to_string(),
889
+ "team_key": team_key,
890
+ "recipient": "leader",
891
+ "leader_attached": false,
892
+ "message_id": message_id,
893
+ })))
894
+ }
895
+
896
+ /// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
897
+ /// - target workspace has state and the team key is present + not archived/down;
898
+ /// - AND at least one live tmux fact — a persisted `session_name` OR any
899
+ /// agent with a recorded pane on the recorded socket.
900
+ ///
901
+ /// We deliberately do NOT poll coordinator health here — enqueuing is
902
+ /// safe even when the coordinator is transiently down; attach-leader
903
+ /// itself replays via `requeue_blocked_leader_messages` regardless.
904
+ fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
905
+ let team = state
906
+ .get("teams")
907
+ .and_then(|v| v.as_object())
908
+ .and_then(|teams| teams.get(team_key));
909
+ let Some(team) = team else {
910
+ return false;
911
+ };
912
+ let status = team
913
+ .get("status")
914
+ .and_then(|v| v.as_str())
915
+ .unwrap_or("alive");
916
+ if matches!(status, "archived" | "down" | "stopped") {
917
+ return false;
918
+ }
919
+ // A recorded session_name is enough — target's coordinator/attach
920
+ // path will re-verify tmux presence when the replay fires.
921
+ team.get("session_name")
922
+ .and_then(|v| v.as_str())
923
+ .is_some_and(|s| !s.is_empty())
924
+ }
925
+
789
926
  fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
790
927
  let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
791
928
  ExitCode::Error
@@ -50,7 +50,7 @@ pub fn attach_leader(
50
50
  if let Some(endpoint) = target.endpoint.as_ref() {
51
51
  receiver.tmux_socket = Some(endpoint.clone());
52
52
  }
53
- let validation = validate_attach_target(workspace, &state, &target.info);
53
+ let validation = validate_attach_target(workspace, &state, &target.info, provider);
54
54
  if validation.is_err() {
55
55
  let pane_info = pane_info_value(&target.info);
56
56
  let targets_value = Value::Array(targets.iter().map(|target| pane_info_value(&target.info)).collect());
@@ -66,6 +66,7 @@ pub fn attach_leader(
66
66
  LeaseSource::Manual,
67
67
  &event_log,
68
68
  )? {
69
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
69
70
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
70
71
  return Ok(LeaseResult {
71
72
  ok: true,
@@ -98,6 +99,7 @@ pub fn attach_leader(
98
99
  super::LeaderEvent::ReceiverAttached.name(),
99
100
  json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
100
101
  )?;
102
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
101
103
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
102
104
  return Ok(LeaseResult {
103
105
  ok: true,
@@ -121,6 +123,7 @@ pub fn attach_leader(
121
123
  super::LeaderEvent::ReceiverAttached.name(),
122
124
  json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
123
125
  )?;
126
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
124
127
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
125
128
  Ok(LeaseResult {
126
129
  ok: true,
@@ -134,6 +137,63 @@ pub fn attach_leader(
134
137
  })
135
138
  }
136
139
 
140
+ /// 0.5.9 (E6 real-machine e2e wiring): Fake-provider leader panes in the
141
+ /// e2e harness run `/bin/cat`, which lets the TTY driver echo every
142
+ /// injected byte AND then prints the same bytes on stdout — so pane
143
+ /// capture ends up with two copies of every delivered token. Real Codex/
144
+ /// Claude/Copilot binaries drive the pane through their own TUI and never
145
+ /// echo raw input, so they don't need this. Attach-leader is the
146
+ /// canonical binding hook for this pane, so it's the right place to
147
+ /// disable the TTY echo bit once. Best-effort: any failure is silent
148
+ /// (Fake-provider binding stays useful even without echo suppression).
149
+ ///
150
+ /// N16/CP-1 (`.team/anchors/REQUIREMENTS.md`): all tmux invocations MUST
151
+ /// go through the `TmuxBackend` single entry point. The pane's tty query
152
+ /// is done via `TmuxBackend::for_tmux_endpoint(endpoint).query(...,
153
+ /// PaneField::PaneTty)` — socket-scoped by construction. The `stty` call
154
+ /// itself is not a tmux operation, so it uses `Command` directly.
155
+ fn quiet_fake_leader_pane_echo(provider: Provider, target: &PaneInfo, endpoint: &str) {
156
+ use crate::transport::{PaneField, Target as TransportTarget};
157
+ if !matches!(provider, Provider::Fake) {
158
+ return;
159
+ }
160
+ if endpoint.is_empty() {
161
+ return;
162
+ }
163
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
164
+ let Ok(Some(tty)) = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::query(
165
+ &backend,
166
+ &TransportTarget::Pane(target.pane_id.clone()),
167
+ PaneField::PaneTty,
168
+ ) else {
169
+ return;
170
+ };
171
+ if tty.trim().is_empty() {
172
+ return;
173
+ }
174
+ // stty against the pane's tty flips the terminal driver's echo bit
175
+ // without asking the pane process (`/bin/cat`) to interpret any
176
+ // command. Best-effort: failure is silent.
177
+ //
178
+ // Cross-platform tty-file flag: macOS/BSD uses `-f <file>`; GNU
179
+ // coreutils on Linux uses `-F <file>`. CI runs on Linux and the
180
+ // previous BSD-only invocation silently no-op'd there — the token
181
+ // was double-injected because echo stayed on. Try `-F` first (Linux
182
+ // is the CI baseline), then fall back to `-f` on macOS. Both are
183
+ // safe to run: the wrong-platform variant just fails silently.
184
+ let tty = tty.trim();
185
+ let linux_ok = std::process::Command::new("stty")
186
+ .args(["-F", tty, "-echo"])
187
+ .output()
188
+ .map(|o| o.status.success())
189
+ .unwrap_or(false);
190
+ if !linux_ok {
191
+ let _ = std::process::Command::new("stty")
192
+ .args(["-f", tty, "-echo"])
193
+ .output();
194
+ }
195
+ }
196
+
137
197
  /// Explicit app-server leader binding. Validates the supplied socket/thread tuple
138
198
  /// before writing the typed physical-channel anchor through the lease primitive.
139
199
  pub fn attach_app_server_leader(
@@ -824,9 +884,26 @@ fn validate_attach_target(
824
884
  workspace: &Path,
825
885
  state: &Value,
826
886
  target: &PaneInfo,
887
+ requested_provider: Provider,
827
888
  ) -> Result<(), &'static str> {
828
- let Some(claim_target) = claim_target_from_pane_info(workspace, target) else {
829
- return Err("leader_pane_validation_failed");
889
+ // 0.5.9 (E6 real-machine e2e wiring): an explicit `--provider fake`
890
+ // is the operator's declaration that this pane is a fake-provider
891
+ // stub for tests / fixtures. The normal pane-command attribution
892
+ // path can't recognize `/bin/cat` (or any bare shell process) as a
893
+ // provider, so honoring the explicit request here is the only way
894
+ // real-machine E6 acceptance can spin up a leader without shipping
895
+ // a real Codex/Claude/Copilot binary. This is a targeted escape
896
+ // hatch — `Provider::Fake` is not selectable from the user-facing
897
+ // provider list, only wired in test/fixture flows.
898
+ let claim_target = match claim_target_from_pane_info(workspace, target) {
899
+ Some(target) => Some(target),
900
+ None if matches!(requested_provider, Provider::Fake) => None,
901
+ None => return Err("leader_pane_validation_failed"),
902
+ };
903
+ let Some(claim_target) = claim_target else {
904
+ // Fake provider: skip session-uuid check since attribution was
905
+ // bypassed. Nothing else to validate.
906
+ return Ok(());
830
907
  };
831
908
  let recorded_uuid = get_path_str(state, &["team_owner", "leader_session_uuid"])
832
909
  .or_else(|| get_path_str(state, &["leader_receiver", "leader_session_uuid"]));
@@ -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
+ }
@@ -108,6 +108,7 @@
108
108
  PaneField::PaneCurrentCommand => "sh",
109
109
  PaneField::PaneCurrentPath => "/tmp/ws",
110
110
  PaneField::SessionName => "team-sess",
111
+ PaneField::PaneTty => "/dev/ttys000",
111
112
  };
112
113
  Ok(Some(value.to_string()))
113
114
  }
@@ -201,6 +201,11 @@ pub enum PaneField {
201
201
  PaneCurrentCommand,
202
202
  PaneCurrentPath,
203
203
  SessionName,
204
+ /// pane's controlling tty (e.g. `/dev/ttys015`). Consumers use it to
205
+ /// flip terminal driver bits like `stty -f <tty> -echo` from outside
206
+ /// tmux's control channel — the query itself still goes through the
207
+ /// backend so N16/CP-1 (single tmux entry point) stays intact.
208
+ PaneTty,
204
209
  }
205
210
 
206
211
  /// 后端种类(诊断/事件用)。
@@ -843,6 +848,7 @@ pub fn tmux_query_argv(pane: &PaneId, field: PaneField) -> Vec<String> {
843
848
  PaneField::PaneCurrentCommand => "#{pane_current_command}",
844
849
  PaneField::PaneCurrentPath => "#{pane_current_path}",
845
850
  PaneField::SessionName => "#{session_name}",
851
+ PaneField::PaneTty => "#{pane_tty}",
846
852
  };
847
853
  let mut argv = vec![
848
854
  "tmux".to_string(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.9",
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.9",
24
- "@team-agent/cli-darwin-x64": "0.5.9",
25
- "@team-agent/cli-linux-x64": "0.5.9"
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",