@team-agent/installer 0.5.116 → 0.5.117

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.117"
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.117"
15
15
  license = "AGPL-3.0"
16
16
  rust-version = "1.95"
17
17
 
@@ -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(
@@ -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
  }
@@ -683,26 +683,33 @@ fn deliver_prepared_message(
683
683
  resolved.metadata.as_ref(),
684
684
  );
685
685
  let recipient_cursor = recipient_is_cursor_agent(state, &message.recipient);
686
- let inject_attempt = crate::tmux_backend::with_paste_to_submit_floor(
687
- paste_to_submit_floor_for_recipient(state, &message.recipient),
688
- || {
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
- })
698
- },
699
- );
686
+ let native_request = recipient_provider(state, &message.recipient)
687
+ .filter(|provider| matches!(provider, Provider::Pi | Provider::CursorAgent))
688
+ .filter(|_| message.recipient != "leader" && transport.supports_owned_native_input())
689
+ .map(|provider| crate::transport::native_input::NativeInputRequest {
690
+ message_id: message_id.to_string(),
691
+ owner_team_id: canonical_owner_team_id.clone().unwrap_or_default(),
692
+ recipient: message.recipient.clone(), provider,
693
+ pane_id: resolved.metadata.as_ref().map(|meta| meta.target_pane_id.clone()).unwrap_or_default(),
694
+ pane_pid: resolved.metadata.as_ref().and_then(|meta| meta.target_pane_pid).unwrap_or_default(),
695
+ session: resolved.metadata.as_ref().map(|meta| meta.target_session.clone()).unwrap_or_default(),
696
+ window: resolved.metadata.as_ref().map(|meta| meta.target_window.clone()).unwrap_or_default(),
697
+ });
698
+ let guarded_native = native_request.is_some();
699
+ let (inject_attempt, native_evidence) = crate::transport::native_input::with_native_input(native_request, || {
700
+ crate::tmux_backend::with_paste_to_submit_floor(
701
+ paste_to_submit_floor_for_recipient(state, &message.recipient),
702
+ || crate::tmux_backend::with_cursor_single_enter(recipient_cursor, || {
703
+ transport.inject_with_submit_observer(&target, &payload, Key::Enter, true, Some(&submit_observer))
704
+ }),
705
+ )
706
+ });
700
707
  let inject_report = match inject_attempt {
701
708
  Ok(report) => {
702
709
  // grok 忙时第一次回车只入显式队列。默认 send-now:只重按回车顶出去。
703
710
  // 无 `Enter:send now` 时零次额外按键,claude/codex 行为不变。
704
711
  // cursor:第二下 Enter 打断进行中回合,不走 flush。
705
- if !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
712
+ if !guarded_native && !crate::provider::submit_now::keep_provider_queue_requested() && !recipient_cursor {
706
713
  match crate::provider::submit_now::flush_explicit_queue(transport, &target) {
707
714
  Ok(flush) if flush.extra_enters > 0 => {
708
715
  let _ = event_log.write(
@@ -732,6 +739,28 @@ fn deliver_prepared_message(
732
739
  }
733
740
  Err(error) => {
734
741
  let reason = format!("inject_failed:{error}");
742
+ if guarded_native {
743
+ let pasted = native_evidence.as_ref().filter(|evidence|
744
+ evidence.phase >= crate::transport::native_input::NativeInputPhase::MayHavePasted);
745
+ let status = if let Some(evidence) = pasted {
746
+ super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
747
+ "submitted_unverified"
748
+ } else {
749
+ super::enter_rescue::release_no_effect(store, message_id, attempt)?;
750
+ "target_resolved"
751
+ };
752
+ event_log.write("native_input.deferred", serde_json::json!({
753
+ "message_id": message_id, "reason": reason, "status": status,
754
+ "may_have_pasted": pasted.is_some(),
755
+ }))?;
756
+ return Ok(DeliveryOutcome {
757
+ ok: false, status: DeliveryStatus::Queued,
758
+ message_status: MessageStatusShadow(status.to_string()),
759
+ message_id: Some(message_id.to_string()), verification: Some(reason),
760
+ stage: Some(DeliveryStage::Inject), reason: None, channel: None,
761
+ ack_forced_off: false, turn_verification: None,
762
+ });
763
+ }
735
764
  if message.recipient == "leader" {
736
765
  store.mark(message_id, "failed", Some("leader_not_attached"))?;
737
766
  event_log.write(
@@ -897,7 +926,11 @@ fn deliver_prepared_message(
897
926
  turn_verification: Some(turn_verification),
898
927
  });
899
928
  }
900
- store.mark(message_id, "submitted_unverified", Some(&reason))?;
929
+ if let Some(evidence) = native_evidence.as_ref() {
930
+ super::enter_rescue::park(store, evidence, attempt, &reason, event_log)?;
931
+ } else {
932
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
933
+ }
901
934
  return Ok(DeliveryOutcome {
902
935
  ok: false,
903
936
  status: DeliveryStatus::Failed,
@@ -2800,6 +2833,40 @@ pub(crate) fn record_turn_open_if_leader_to_worker(
2800
2833
  )
2801
2834
  }
2802
2835
 
2836
+ /// A rescue that supplies the first successful Enter must arm the same turn
2837
+ /// linkage as normal delivery, immediately after the key (not after UI polling).
2838
+ pub(super) fn with_rescue_submit_observer<R>(
2839
+ workspace: &Path,
2840
+ store: &MessageStore,
2841
+ evidence: &crate::transport::native_input::NativeInputEvidence,
2842
+ event_log: &EventLog,
2843
+ execute: impl FnOnce(&dyn SubmitObserver) -> Result<R, MessagingError>,
2844
+ ) -> Result<R, MessagingError> {
2845
+ let request = &evidence.request;
2846
+ let conn = crate::db::schema::open_db(store.db_path())?;
2847
+ let sender: String = conn.query_row(
2848
+ "select sender from messages where message_id=?1 and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
2849
+ params![request.message_id, request.owner_team_id, request.recipient],
2850
+ |row| row.get(0),
2851
+ )?;
2852
+ let metadata = TargetMetadata {
2853
+ tmux_endpoint: Some(evidence.binding.endpoint.clone()),
2854
+ target_session: evidence.binding.session.clone(),
2855
+ target_window: evidence.binding.window.clone(),
2856
+ target_pane_id: evidence.binding.pane_id.clone(),
2857
+ target_pane_pid: Some(evidence.binding.pane_pid),
2858
+ resolved_from: "enter_rescue_owned_binding",
2859
+ };
2860
+ let mut observer = CurrentTurnSubmitObserver::new(
2861
+ workspace, &sender, &request.recipient, &request.message_id, event_log,
2862
+ Some(&request.owner_team_id), Some(&metadata),
2863
+ );
2864
+ observer.rescue_evidence = Some(evidence);
2865
+ let result = execute(&observer)?;
2866
+ if let Some(error) = observer.take_error() { return Err(error); }
2867
+ Ok(result)
2868
+ }
2869
+
2803
2870
  struct CurrentTurnSubmitObserver<'a> {
2804
2871
  workspace: &'a Path,
2805
2872
  sender: &'a str,
@@ -2808,6 +2875,7 @@ struct CurrentTurnSubmitObserver<'a> {
2808
2875
  event_log: &'a EventLog,
2809
2876
  owner_team_id: Option<&'a str>,
2810
2877
  metadata: Option<&'a TargetMetadata>,
2878
+ rescue_evidence: Option<&'a crate::transport::native_input::NativeInputEvidence>,
2811
2879
  fired: Cell<bool>,
2812
2880
  error: RefCell<Option<MessagingError>>,
2813
2881
  }
@@ -2830,6 +2898,7 @@ impl<'a> CurrentTurnSubmitObserver<'a> {
2830
2898
  event_log,
2831
2899
  owner_team_id,
2832
2900
  metadata,
2901
+ rescue_evidence: None,
2833
2902
  fired: Cell::new(false),
2834
2903
  error: RefCell::new(None),
2835
2904
  }
@@ -2845,20 +2914,56 @@ impl SubmitObserver for CurrentTurnSubmitObserver<'_> {
2845
2914
  if self.fired.replace(true) {
2846
2915
  return;
2847
2916
  }
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
- }
2917
+ let result = if let Some(evidence) = self.rescue_evidence {
2918
+ record_rescue_turn_after_submit(self.workspace, self.sender, evidence, self.event_log, self.metadata)
2919
+ } else {
2920
+ record_current_turn_after_inject_if_leader_to_worker_scoped(
2921
+ self.workspace, self.sender, self.recipient, self.message_id,
2922
+ self.event_log, self.owner_team_id, self.metadata,
2923
+ )
2924
+ };
2925
+ if let Err(error) = result { *self.error.borrow_mut() = Some(error); }
2859
2926
  }
2860
2927
  }
2861
2928
 
2929
+ fn record_rescue_turn_after_submit(
2930
+ workspace: &Path,
2931
+ sender: &str,
2932
+ evidence: &crate::transport::native_input::NativeInputEvidence,
2933
+ event_log: &EventLog,
2934
+ metadata: Option<&TargetMetadata>,
2935
+ ) -> Result<(), MessagingError> {
2936
+ let request = &evidence.request;
2937
+ if !matches!(sender, "leader" | "Leader") { return Ok(()); }
2938
+ crate::state::repository::StateRepository::new(workspace).commit_messaging_checked(
2939
+ crate::state::repository::StateWriteIntent::MessagingDeliveryState {
2940
+ owner_team_id: Some(&request.owner_team_id),
2941
+ },
2942
+ |selected| {
2943
+ let agent = selected.get("agents").and_then(|agents| agents.get(&request.recipient));
2944
+ let matches = agent.is_some_and(|agent| {
2945
+ agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
2946
+ && agent.get("explicit_stop").and_then(serde_json::Value::as_bool) != Some(true)
2947
+ && agent.get("pane_id").and_then(serde_json::Value::as_str) == Some(request.pane_id.as_str())
2948
+ && agent.get("pane_pid").and_then(serde_json::Value::as_u64) == Some(u64::from(request.pane_pid))
2949
+ && request.pane_pid == evidence.binding.pane_pid
2950
+ && agent.get("window").and_then(serde_json::Value::as_str).unwrap_or(&request.recipient) == request.window
2951
+ && agent.get("provider").cloned().and_then(|value| serde_json::from_value::<Provider>(value).ok()) == Some(request.provider)
2952
+ && selected.get("session_name").and_then(serde_json::Value::as_str) == Some(request.session.as_str())
2953
+ });
2954
+ if !matches {
2955
+ return Err(crate::state::StateError::SaveConflict("rescue receiver changed before turn commit".into()));
2956
+ }
2957
+ arm_turn_open(selected, &request.recipient, &Some(request.message_id.clone()));
2958
+ Ok(())
2959
+ },
2960
+ )?;
2961
+ let mut event = serde_json::json!({"agent_id": request.recipient, "message_id": request.message_id});
2962
+ if let Some(metadata) = metadata { append_target_metadata(&mut event, metadata); }
2963
+ event_log.write("turn_open.armed_after_inject", event)?;
2964
+ Ok(())
2965
+ }
2966
+
2862
2967
  fn record_current_turn_after_inject_if_leader_to_worker_scoped(
2863
2968
  workspace: &Path,
2864
2969
  sender: &str,