@team-agent/installer 0.5.116 → 0.5.118

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
@@ -727,7 +727,7 @@ dependencies = [
727
727
 
728
728
  [[package]]
729
729
  name = "team-agent"
730
- version = "0.5.116"
730
+ version = "0.5.118"
731
731
  dependencies = [
732
732
  "anyhow",
733
733
  "chrono",
package/Cargo.toml CHANGED
@@ -11,7 +11,7 @@ exclude = ["crates/team-agent-contract"]
11
11
 
12
12
  [workspace.package]
13
13
  edition = "2021"
14
- version = "0.5.116"
14
+ version = "0.5.118"
15
15
  license = "AGPL-3.0"
16
16
  rust-version = "1.95"
17
17
 
@@ -424,6 +424,26 @@ pub mod lifecycle_port {
424
424
  });
425
425
  if let Some(object) = value.as_object_mut() {
426
426
  object.extend(facts);
427
+ // Only a plain Pi entry can be retried verbatim with this command.
428
+ // Never drop explicit attach/external intent or provider arguments,
429
+ // clear the caller's environment, or adopt the unverified pane.
430
+ if cfg!(unix)
431
+ && provider == Provider::Pi
432
+ && attach == &LeaderLauncherArgs::default()
433
+ {
434
+ let command = "env -u TMUX -u TMUX_PANE team-agent pi";
435
+ object.insert("recovery_command".to_string(), json!(command));
436
+ object.insert(
437
+ ACTION_FIELD.to_string(),
438
+ json!(format!(
439
+ "The inherited TMUX/TMUX_PANE could not be authorized for \
440
+ requested_workspace; this is not an empty-directory error. \
441
+ To explicitly retry from requested_workspace, run `{command}`. \
442
+ This removes the inherited variables only for the new process \
443
+ and does not detach or modify your current tmux pane. {action}"
444
+ )),
445
+ );
446
+ }
427
447
  }
428
448
  value
429
449
  }
@@ -4,8 +4,6 @@
4
4
 
5
5
  use std::collections::BTreeSet;
6
6
  use std::path::{Path, PathBuf};
7
- use std::sync::atomic::{AtomicU64, Ordering};
8
- use std::time::{SystemTime, UNIX_EPOCH};
9
7
 
10
8
  use serde_json::{json, Value};
11
9
  use sha2::{Digest, Sha256};
@@ -1837,22 +1835,7 @@ fn canonical_workspace(workspace: &Path) -> PathBuf {
1837
1835
  }
1838
1836
 
1839
1837
  fn next_pane_binding_nonce(workspace: &Path, endpoint: &str, pane: &PaneId) -> String {
1840
- static COUNTER: AtomicU64 = AtomicU64::new(0);
1841
- let nanos = SystemTime::now()
1842
- .duration_since(UNIX_EPOCH)
1843
- .map_or(0, |duration| duration.as_nanos());
1844
- let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
1845
- let material = format!(
1846
- "{}\0{endpoint}\0{}\0{}\0{nanos}\0{counter}",
1847
- canonical_workspace(workspace).display(),
1848
- pane.as_str(),
1849
- std::process::id(),
1850
- );
1851
- let digest = Sha256::digest(material.as_bytes());
1852
- digest[..16]
1853
- .iter()
1854
- .map(|byte| format!("{byte:02x}"))
1855
- .collect()
1838
+ crate::tmux_backend::next_pane_binding_nonce(workspace, endpoint, pane)
1856
1839
  }
1857
1840
 
1858
1841
  fn pane_binding_nonce_for_claim(
@@ -48,9 +48,6 @@ enum ManagedClientAttachMode {
48
48
  SwitchClient,
49
49
  }
50
50
 
51
- const DIFFERENT_TMUX_SERVER_PREFIX: &str =
52
- "managed launcher refuses a different ambient tmux server";
53
-
54
51
  impl PreparedLeaderStart {
55
52
  pub(crate) fn plan(&self) -> &LeaderStartPlan {
56
53
  &self.plan
@@ -141,8 +138,6 @@ pub(crate) fn prepare_leader_start_with_nested_attach(
141
138
  let (ambient_authority, managed_client_attach_mode, managed_exec_provider) =
142
139
  if explicit_external_path || state_external_path {
143
140
  (ambient_pane_authority_preflight(workspace)?, None, false)
144
- } else if workspace_state_is_managed_provider_reentry(workspace) {
145
- (ambient_pane_authority_preflight(workspace)?, None, true)
146
141
  } else {
147
142
  managed_launcher_ambient_route(
148
143
  provider,
@@ -159,7 +154,7 @@ pub(crate) fn prepare_leader_start_with_nested_attach(
159
154
  confirm_attach,
160
155
  attach_session,
161
156
  external_leader,
162
- std::env::var_os("TMUX").is_some(),
157
+ ambient_authority.is_some(),
163
158
  managed_client_attach_mode,
164
159
  managed_exec_provider,
165
160
  allow_nested_attach,
@@ -613,64 +608,49 @@ fn managed_launcher_ambient_route(
613
608
  ),
614
609
  PrepareLeaderStartError,
615
610
  > {
616
- let Some(tmux) = std::env::var_os("TMUX") else {
617
- return Ok((
618
- None,
619
- Some(ManagedClientAttachMode::AttachSession),
620
- false,
621
- ));
611
+ let isolated = || (None, Some(ManagedClientAttachMode::AttachSession), false);
612
+ let authority = match ambient_pane_authority_preflight(workspace) {
613
+ Ok(Some(authority)) => authority,
614
+ Ok(None) => return Ok(isolated()),
615
+ Err(PrepareLeaderStartError::PaneAuthorityRefused(_)) if !allow_nested_attach => {
616
+ // An inherited tuple cannot authorize adoption, but an ordinary
617
+ // managed launch can still create its own workspace pane. Keep
618
+ // explicit external/attach and nested-attach intent fail-closed.
619
+ return Ok(isolated());
620
+ }
621
+ Err(error) => return Err(error),
622
622
  };
623
- let tmux = tmux.into_string().map_err(|_| {
624
- PrepareLeaderStartError::PaneAuthorityRefused(ambient_tmux_endpoint_refusal(
625
- workspace,
626
- pane_refusal::AmbientTmuxEndpointUnavailableCause::TmuxValueNotUnicode,
627
- ))
628
- })?;
629
- let observed_endpoint = validated_ambient_tmux_endpoint(workspace, &tmux)
630
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
631
- if provider == Provider::Pi && provider_args.is_empty() && !allow_nested_attach {
632
- // Pi's zero-argument managed entry already owns this verified pane as its
633
- // user-facing terminal. Replacing the current process is safe on either
634
- // the workspace server or another server; switching/attaching would move
635
- // the user away from the positively verified workspace pane.
636
- let authority = verified_ambient_pane_authority(workspace, &tmux)
637
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
623
+ if workspace_state_is_managed_provider_reentry(workspace)
624
+ || (provider == Provider::Pi && provider_args.is_empty() && !allow_nested_attach)
625
+ {
626
+ // Only verified current-pane authority permits replacing the caller.
638
627
  return Ok((Some(authority), None, true));
639
628
  }
640
- let target_socket_name = TmuxBackend::for_workspace(workspace)
641
- .tmux_endpoint()
642
- .ok_or_else(|| LeaderError::Start("workspace tmux endpoint missing".to_string()))?;
643
629
  let target_endpoint = crate::tmux_backend::socket_path_for_workspace(workspace);
644
630
  let same_server = target_endpoint.as_ref().is_some_and(|target| {
645
- let observed = Path::new(&observed_endpoint)
631
+ let observed = Path::new(&authority.endpoint)
646
632
  .canonicalize()
647
- .unwrap_or_else(|_| PathBuf::from(&observed_endpoint));
633
+ .unwrap_or_else(|_| PathBuf::from(&authority.endpoint));
648
634
  let target = target.canonicalize().unwrap_or_else(|_| target.clone());
649
635
  observed == target
650
636
  });
651
637
  if same_server {
652
- let authority = verified_ambient_pane_authority(workspace, &tmux)
653
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
654
638
  return Ok((
655
639
  Some(authority),
656
640
  Some(ManagedClientAttachMode::SwitchClient),
657
641
  false,
658
642
  ));
659
643
  }
660
- if !allow_nested_attach {
661
- return Err(LeaderError::Validation(format!(
662
- "{DIFFERENT_TMUX_SERVER_PREFIX}: observed_endpoint={observed_endpoint}; \
663
- requested_workspace_socket={target_socket_name}"
664
- ))
665
- .into());
644
+ if allow_nested_attach {
645
+ return Ok((
646
+ Some(authority),
647
+ Some(ManagedClientAttachMode::AttachSession),
648
+ false,
649
+ ));
666
650
  }
667
- let authority = verified_ambient_pane_authority(workspace, &tmux)
668
- .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
669
- Ok((
670
- Some(authority),
671
- Some(ManagedClientAttachMode::AttachSession),
672
- false,
673
- ))
651
+ // Do not switch/adopt the unrelated server. The existing managed attach
652
+ // child clears TMUX/TMUX_PANE without changing the caller's environment.
653
+ Ok(isolated())
674
654
  }
675
655
 
676
656
  fn validated_ambient_tmux_endpoint(
@@ -36,6 +36,7 @@ pub(crate) struct PaneInputLockRequest<'a> {
36
36
 
37
37
  pub(crate) struct PaneInputLockGuard {
38
38
  file: Option<std::fs::File>,
39
+ guard_gate: Option<std::fs::File>,
39
40
  }
40
41
 
41
42
  /// ---
@@ -54,18 +55,58 @@ pub(crate) fn acquire_or_proceed(request: PaneInputLockRequest<'_>) -> Option<Pa
54
55
  // No workspace (some unit backends): nothing to lock against. Not an alarm.
55
56
  return None;
56
57
  };
57
- acquire_file_lock(workspace, &request, timeout)
58
+ acquire_file_lock(workspace, &request, timeout, "proceed")
59
+ }
60
+
61
+ /// Every legacy writer holds shared admission through its whole operation,
62
+ /// even when its ordinary 200ms lock times out. Required guards hold exclusive
63
+ /// admission, so neither can enter while the other is already writing.
64
+ pub(crate) fn acquire_for_write(request: PaneInputLockRequest<'_>) -> Result<Option<PaneInputLockGuard>, ()> {
65
+ if request.workspace.is_none() { return Ok(None); }
66
+ let gate = acquire_guard_gate(&request, false)?;
67
+ let mut guard = acquire_or_proceed(request).unwrap_or(PaneInputLockGuard { file: None, guard_gate: None });
68
+ guard.guard_gate = Some(gate);
69
+ Ok(Some(guard))
70
+ }
71
+
72
+ fn acquire_guard_gate(request: &PaneInputLockRequest<'_>, exclusive: bool) -> Result<std::fs::File, ()> {
73
+ let opened = (|| -> std::io::Result<std::fs::File> {
74
+ let workspace = request.workspace.ok_or_else(|| std::io::Error::other("workspace missing"))?;
75
+ let path = pane_input_lock_path(workspace, request.target_key).with_extension("guard");
76
+ if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
77
+ let file = std::fs::OpenOptions::new().create(true).read(true).write(true).truncate(false).open(path)?;
78
+ let result = if exclusive { file.try_lock() } else { file.try_lock_shared() };
79
+ result.map_err(|error| match error {
80
+ std::fs::TryLockError::WouldBlock => std::io::Error::from(std::io::ErrorKind::WouldBlock),
81
+ std::fs::TryLockError::Error(error) => error,
82
+ })?;
83
+ Ok(file)
84
+ })();
85
+ opened.map_err(|_| emit_timeout_alarm(request, PANE_INPUT_LOCK_TIMEOUT, Duration::ZERO, "guard_gate", "refuse"))
86
+ }
87
+
88
+ /// Guarded native input must never treat a missing/contended lock as permission.
89
+ pub(crate) fn acquire_required(request: PaneInputLockRequest<'_>) -> Option<PaneInputLockGuard> {
90
+ let Some(workspace) = request.workspace else {
91
+ emit_timeout_alarm(&request, PANE_INPUT_LOCK_TIMEOUT, Duration::ZERO, "workspace_missing", "refuse");
92
+ return None;
93
+ };
94
+ let gate = acquire_guard_gate(&request, true).ok()?;
95
+ let mut guard = acquire_file_lock(workspace, &request, PANE_INPUT_LOCK_TIMEOUT, "refuse")?;
96
+ guard.guard_gate = Some(gate);
97
+ Some(guard)
58
98
  }
59
99
 
60
100
  fn acquire_file_lock(
61
101
  workspace: &Path,
62
102
  request: &PaneInputLockRequest<'_>,
63
103
  timeout: Duration,
104
+ disposition: &str,
64
105
  ) -> Option<PaneInputLockGuard> {
65
106
  let path = pane_input_lock_path(workspace, request.target_key);
66
107
  if let Some(parent) = path.parent() {
67
108
  if std::fs::create_dir_all(parent).is_err() {
68
- emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_dir");
109
+ emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_dir", disposition);
69
110
  return None;
70
111
  }
71
112
  }
@@ -78,7 +119,7 @@ fn acquire_file_lock(
78
119
  {
79
120
  Ok(file) => file,
80
121
  Err(_) => {
81
- emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_open");
122
+ emit_timeout_alarm(request, timeout, Duration::ZERO, "lock_open", disposition);
82
123
  return None;
83
124
  }
84
125
  };
@@ -86,17 +127,17 @@ fn acquire_file_lock(
86
127
  loop {
87
128
  match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
88
129
  Ok(true) => {
89
- return Some(PaneInputLockGuard { file: Some(file) });
130
+ return Some(PaneInputLockGuard { file: Some(file), guard_gate: None });
90
131
  }
91
132
  Ok(false) => {}
92
133
  Err(_) => {
93
- emit_timeout_alarm(request, timeout, started.elapsed(), "lock_io");
134
+ emit_timeout_alarm(request, timeout, started.elapsed(), "lock_io", disposition);
94
135
  return None;
95
136
  }
96
137
  }
97
138
  let elapsed = started.elapsed();
98
139
  if elapsed >= timeout {
99
- emit_timeout_alarm(request, timeout, elapsed, "timeout");
140
+ emit_timeout_alarm(request, timeout, elapsed, "timeout", disposition);
100
141
  return None;
101
142
  }
102
143
  std::thread::sleep(std::cmp::min(
@@ -127,10 +168,11 @@ fn emit_timeout_alarm(
127
168
  timeout: Duration,
128
169
  waited: Duration,
129
170
  reason: &str,
171
+ disposition: &str,
130
172
  ) {
131
173
  // A5 watches this exact token on stderr. Commenting this line must turn A5 red.
132
174
  eprintln!(
133
- "{PANE_INPUT_LOCK_TIMEOUT_EVENT} target={} operation={} waited_ms={} timeout_ms={} reason={reason} proceeding",
175
+ "{PANE_INPUT_LOCK_TIMEOUT_EVENT} target={} operation={} waited_ms={} timeout_ms={} reason={reason} disposition={disposition}",
134
176
  request.target_key,
135
177
  request.operation,
136
178
  waited.as_millis(),
@@ -146,7 +188,7 @@ fn emit_timeout_alarm(
146
188
  "waited_ms": waited.as_millis() as u64,
147
189
  "timeout_ms": timeout.as_millis() as u64,
148
190
  "reason": reason,
149
- "disposition": "proceed",
191
+ "disposition": disposition,
150
192
  }),
151
193
  );
152
194
  }
@@ -157,5 +199,41 @@ impl Drop for PaneInputLockGuard {
157
199
  if let Some(file) = self.file.take() {
158
200
  let _ = crate::platform::file_lock::unlock(&file);
159
201
  }
202
+ if let Some(gate) = self.guard_gate.take() { let _ = gate.unlock(); }
203
+ }
204
+ }
205
+
206
+ #[cfg(test)]
207
+ mod required_tests {
208
+ #![allow(clippy::unwrap_used)]
209
+ use super::*;
210
+
211
+ #[test]
212
+ fn required_pane_input_lock_missing_workspace_refuses() {
213
+ assert!(acquire_required(PaneInputLockRequest { workspace: None, target_key: "%1", operation: "fixture" }).is_none());
214
+ }
215
+
216
+ #[test]
217
+ fn required_pane_input_lock_contention_refuses_and_drop_releases_same_key() {
218
+ let workspace = std::env::temp_dir().join(format!("ta-required-pane-lock-{}", std::process::id()));
219
+ std::fs::create_dir(&workspace).unwrap();
220
+ let request = || PaneInputLockRequest { workspace: Some(workspace.as_path()), target_key: "%1", operation: "fixture" };
221
+ let held = acquire_required(request()).unwrap();
222
+ assert!(acquire_required(request()).is_none());
223
+ let events = std::fs::read_to_string(workspace.join(".team/logs/events.jsonl")).unwrap();
224
+ assert!(events.lines().filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
225
+ .any(|row| row.to_string().contains("\"disposition\":\"refuse\"")));
226
+ drop(held);
227
+ drop(acquire_required(request()).unwrap());
228
+ // Preserve the old timeout/proceed policy, but hold reader admission
229
+ // through that unheld-main-lock writer so no required guard crosses it.
230
+ let first_writer = acquire_for_write(request()).unwrap().unwrap();
231
+ let timed_out_writer = acquire_for_write(request()).unwrap().unwrap();
232
+ assert!(timed_out_writer.file.is_none());
233
+ drop(first_writer);
234
+ assert!(acquire_required(request()).is_none());
235
+ drop(timed_out_writer);
236
+ drop(acquire_required(request()).unwrap());
237
+ std::fs::remove_dir_all(workspace).unwrap();
160
238
  }
161
239
  }
@@ -4,7 +4,7 @@ use std::time::Duration;
4
4
 
5
5
  use crate::lifecycle::launch::pi_mcp::pi_seat_paths;
6
6
  use crate::messaging::delivery::{
7
- paste_to_submit_floor_for_recipient, recipient_allows_explicit_queue_flush, recipient_is_busy,
7
+ paste_to_submit_floor_for_recipient, recipient_allows_explicit_queue_flush,
8
8
  recipient_requires_single_enter,
9
9
  };
10
10
  use crate::model::enums::{Provider, ProviderEffort};
@@ -186,17 +186,12 @@ fn pi_delivery_submits_one_enter_and_never_flushes_or_retries() {
186
186
  paste_to_submit_floor_for_recipient(&idle, "worker-a"),
187
187
  Duration::ZERO
188
188
  );
189
- assert!(!recipient_is_busy(&idle, "worker-a"));
190
-
191
189
  let busy = serde_json::json!({
192
190
  "agents": {
193
191
  "worker-a": {"provider": "pi", "status": "busy"}
194
192
  }
195
193
  });
196
- assert!(
197
- recipient_is_busy(&busy, "worker-a"),
198
- "known busy must take the existing send.deferred_busy path before paste"
199
- );
194
+ // Working native CLIs queue the same single-Enter input; no idle admission.
200
195
  assert!(recipient_requires_single_enter(&busy, "worker-a"));
201
196
  assert!(!recipient_allows_explicit_queue_flush(&busy, "worker-a"));
202
197
  }
@@ -299,34 +299,6 @@ fn prepare_message_state(
299
299
  }
300
300
  }
301
301
  }
302
- if batch
303
- && recipient_is_busy(
304
- prepared.scoped.as_ref().unwrap_or(state),
305
- &message.recipient,
306
- )
307
- {
308
- event_log.write(
309
- "send.deferred_busy",
310
- serde_json::json!({
311
- "message_id": message_id,
312
- "sender": message.sender,
313
- "recipient": message.recipient,
314
- "reason": "recipient_busy",
315
- }),
316
- )?;
317
- return Ok(Err(DeliveryOutcome {
318
- ok: false,
319
- status: DeliveryStatus::Queued,
320
- message_status: MessageStatusShadow(message.status.clone()),
321
- message_id: Some(message_id.to_string()),
322
- verification: None,
323
- stage: None,
324
- reason: None,
325
- channel: None,
326
- ack_forced_off: false,
327
- turn_verification: None,
328
- }));
329
- }
330
302
  Ok(Ok(prepared))
331
303
  }
332
304
 
@@ -682,19 +654,23 @@ fn deliver_prepared_message(
682
654
  canonical_owner_team_id.as_deref(),
683
655
  resolved.metadata.as_ref(),
684
656
  );
685
- let recipient_cursor = recipient_is_cursor_agent(state, &message.recipient);
657
+ // v0.5.113 delivery: address/owner and pane serialization are authoritative;
658
+ // composer appearance is not admission. Native CLIs buffer/queue input.
686
659
  let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
687
660
  paste_to_submit_floor_for_recipient(state, &message.recipient),
688
661
  || {
689
- crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
690
- transport.inject_with_submit_observer(
691
- &target,
692
- &payload,
693
- Key::Enter,
694
- true,
695
- Some(&submit_observer),
696
- )
697
- })
662
+ crate::tmux_backend::with_cursor_single_enter(
663
+ recipient_requires_single_enter(state, &message.recipient),
664
+ || {
665
+ transport.inject_with_submit_observer(
666
+ &target,
667
+ &payload,
668
+ Key::Enter,
669
+ true,
670
+ Some(&submit_observer),
671
+ )
672
+ },
673
+ )
698
674
  },
699
675
  );
700
676
  let inject_report = match inject_attempt {
@@ -702,7 +678,9 @@ fn deliver_prepared_message(
702
678
  // grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
703
679
  // 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
704
680
  // cursor:第二下 Enter 打断进行中回合,不走 flush。
705
- if !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
681
+ if !crate::provider::submit_now::keep_provider_queue_requested()
682
+ && recipient_allows_explicit_queue_flush(state, &message.recipient)
683
+ {
706
684
  match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
707
685
  Ok(flush) if flush.extra_enters > 0 => {
708
686
  let _ = event_log.write(
@@ -2272,8 +2250,8 @@ fn only_team_entry(state: &serde_json::Value) -> Option<&serde_json::Value> {
2272
2250
  }
2273
2251
  }
2274
2252
 
2275
- /// `_deliver_pending_messages` (`delivery.py:484`):扫 pending 队列逐条投递;busy 收件人写
2276
- /// `send.deferred_busy` 跳过 (**不丢**,card §131)。返回投递的 message_id 列表。
2253
+ /// Deliver pending rows without activity/composer admission; native CLIs queue input.
2254
+ /// Returns the message IDs whose post-submit evidence verified delivery.
2277
2255
  pub fn deliver_pending_messages(
2278
2256
  workspace: &Path,
2279
2257
  state: &serde_json::Value,
@@ -2467,13 +2445,6 @@ fn recipient_provider(state: &serde_json::Value, recipient: &str) -> Option<Prov
2467
2445
  .and_then(parse_canonical_provider)
2468
2446
  }
2469
2447
 
2470
- fn recipient_is_cursor_agent(state: &serde_json::Value, recipient: &str) -> bool {
2471
- matches!(
2472
- recipient_provider(state, recipient),
2473
- Some(Provider::CursorAgent)
2474
- )
2475
- }
2476
-
2477
2448
  /// ---
2478
2449
  /// purpose: paste→Enter 地板按收件人 provider 选择
2479
2450
  /// contract: CursorAgent 与 Grok 为 1s;其余(含 claude)为 ZERO
@@ -2516,16 +2487,6 @@ pub(crate) fn recipient_allows_explicit_queue_flush(
2516
2487
  )
2517
2488
  }
2518
2489
 
2519
- pub(crate) fn recipient_is_busy(state: &serde_json::Value, recipient: &str) -> bool {
2520
- state
2521
- .get("agents")
2522
- .and_then(serde_json::Value::as_object)
2523
- .and_then(|agents| agents.get(recipient))
2524
- .and_then(|agent| agent.get("status"))
2525
- .and_then(serde_json::Value::as_str)
2526
- == Some("busy")
2527
- }
2528
-
2529
2490
  fn leader_rollout_path(state: &serde_json::Value) -> Option<std::path::PathBuf> {
2530
2491
  if let Some(receiver) = leader_receiver_value(state).filter(|receiver| {
2531
2492
  receiver.get("provider").and_then(serde_json::Value::as_str) == Some("pi")
@@ -2800,6 +2761,40 @@ pub(crate) fn record_turn_open_if_leader_to_worker(
2800
2761
  )
2801
2762
  }
2802
2763
 
2764
+ /// A rescue that supplies the first successful Enter must arm the same turn
2765
+ /// linkage as normal delivery, immediately after the key (not after UI polling).
2766
+ pub(super) fn with_rescue_submit_observer<R>(
2767
+ workspace: &Path,
2768
+ store: &MessageStore,
2769
+ evidence: &crate::transport::native_input::NativeInputEvidence,
2770
+ event_log: &EventLog,
2771
+ execute: impl FnOnce(&dyn SubmitObserver) -> Result<R, MessagingError>,
2772
+ ) -> Result<R, MessagingError> {
2773
+ let request = &evidence.request;
2774
+ let conn = crate::db::schema::open_db(store.db_path())?;
2775
+ let sender: String = conn.query_row(
2776
+ "select sender from messages where message_id=?1 and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
2777
+ params![request.message_id, request.owner_team_id, request.recipient],
2778
+ |row| row.get(0),
2779
+ )?;
2780
+ let metadata = TargetMetadata {
2781
+ tmux_endpoint: Some(evidence.binding.endpoint.clone()),
2782
+ target_session: evidence.binding.session.clone(),
2783
+ target_window: evidence.binding.window.clone(),
2784
+ target_pane_id: evidence.binding.pane_id.clone(),
2785
+ target_pane_pid: Some(evidence.binding.pane_pid),
2786
+ resolved_from: "enter_rescue_owned_binding",
2787
+ };
2788
+ let mut observer = CurrentTurnSubmitObserver::new(
2789
+ workspace, &sender, &request.recipient, &request.message_id, event_log,
2790
+ Some(&request.owner_team_id), Some(&metadata),
2791
+ );
2792
+ observer.rescue_evidence = Some(evidence);
2793
+ let result = execute(&observer)?;
2794
+ if let Some(error) = observer.take_error() { return Err(error); }
2795
+ Ok(result)
2796
+ }
2797
+
2803
2798
  struct CurrentTurnSubmitObserver<'a> {
2804
2799
  workspace: &'a Path,
2805
2800
  sender: &'a str,
@@ -2808,6 +2803,7 @@ struct CurrentTurnSubmitObserver<'a> {
2808
2803
  event_log: &'a EventLog,
2809
2804
  owner_team_id: Option<&'a str>,
2810
2805
  metadata: Option<&'a TargetMetadata>,
2806
+ rescue_evidence: Option<&'a crate::transport::native_input::NativeInputEvidence>,
2811
2807
  fired: Cell<bool>,
2812
2808
  error: RefCell<Option<MessagingError>>,
2813
2809
  }
@@ -2830,6 +2826,7 @@ impl<'a> CurrentTurnSubmitObserver<'a> {
2830
2826
  event_log,
2831
2827
  owner_team_id,
2832
2828
  metadata,
2829
+ rescue_evidence: None,
2833
2830
  fired: Cell::new(false),
2834
2831
  error: RefCell::new(None),
2835
2832
  }
@@ -2845,20 +2842,56 @@ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
2845
2842
  if self.fired.replace(true) {
2846
2843
  return;
2847
2844
  }
2848
- if let Err(error) = record_current_turn_after_inject_if_leader_to_worker_scoped(
2849
- self.workspace,
2850
- self.sender,
2851
- self.recipient,
2852
- self.message_id,
2853
- self.event_log,
2854
- self.owner_team_id,
2855
- self.metadata,
2856
- ) {
2857
- *self.error.borrow_mut() = Some(error);
2858
- }
2845
+ let result = if let Some(evidence) = self.rescue_evidence {
2846
+ record_rescue_turn_after_submit(self.workspace, self.sender, evidence, self.event_log, self.metadata)
2847
+ } else {
2848
+ record_current_turn_after_inject_if_leader_to_worker_scoped(
2849
+ self.workspace, self.sender, self.recipient, self.message_id,
2850
+ self.event_log, self.owner_team_id, self.metadata,
2851
+ )
2852
+ };
2853
+ if let Err(error) = result { *self.error.borrow_mut() = Some(error); }
2859
2854
  }
2860
2855
  }
2861
2856
 
2857
+ fn record_rescue_turn_after_submit(
2858
+ workspace: &Path,
2859
+ sender: &str,
2860
+ evidence: &crate::transport::native_input::NativeInputEvidence,
2861
+ event_log: &EventLog,
2862
+ metadata: Option<&TargetMetadata>,
2863
+ ) -> Result<(), MessagingError> {
2864
+ let request = &evidence.request;
2865
+ if !matches!(sender, "leader" | "Leader") { return Ok(()); }
2866
+ crate::state::repository::StateRepository::new(workspace).commit_messaging_checked(
2867
+ crate::state::repository::StateWriteIntent::MessagingDeliveryState {
2868
+ owner_team_id: Some(&request.owner_team_id),
2869
+ },
2870
+ |selected| {
2871
+ let agent = selected.get("agents").and_then(|agents| agents.get(&request.recipient));
2872
+ let matches = agent.is_some_and(|agent| {
2873
+ agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
2874
+ && agent.get("explicit_stop").and_then(serde_json::Value::as_bool) != Some(true)
2875
+ && agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(request.pane_id.as_str())
2876
+ && agent.get("pane_pid").and_then(serde_json::Value::as_u64) == Some(u64::from(request.pane_pid))
2877
+ && request.pane_pid == evidence.binding.pane_pid
2878
+ && agent.get("window").and_then(serde_json::Value::as_str).unwrap_or(&request.recipient) == request.window
2879
+ && agent.get("provider").cloned().and_then(|value| serde_json::from_value::<Provider>(value).ok()) == Some(request.provider)
2880
+ && selected.get("session_name").and_then(serde_json::Value::as_str) == Some(request.session.as_str())
2881
+ });
2882
+ if !matches {
2883
+ return Err(crate::state::StateError::SaveConflict("rescue receiver changed before turn commit".into()));
2884
+ }
2885
+ arm_turn_open(selected, &request.recipient, &Some(request.message_id.clone()));
2886
+ Ok(())
2887
+ },
2888
+ )?;
2889
+ let mut event = serde_json::json!({"agent_id": request.recipient, "message_id": request.message_id});
2890
+ if let Some(metadata) = metadata { append_target_metadata(&mut event, metadata); }
2891
+ event_log.write("turn_open.armed_after_inject", event)?;
2892
+ Ok(())
2893
+ }
2894
+
2862
2895
  fn record_current_turn_after_inject_if_leader_to_worker_scoped(
2863
2896
  workspace: &Path,
2864
2897
  sender: &str,