@team-agent/installer 0.5.117 → 0.5.119

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.
@@ -64,7 +64,7 @@ use crate::transport::{PaneId, Target, Transport};
64
64
  pub mod activity;
65
65
  pub mod address;
66
66
  pub mod delivery;
67
- mod enter_rescue;
67
+ pub(crate) mod enter_rescue;
68
68
  pub mod helpers;
69
69
  pub mod leader_channel;
70
70
  pub mod leader_receiver;
@@ -735,7 +735,7 @@ fn resource188_batch_and_public_single_prepare_once_and_keep_fresh_leader_load()
735
735
  }
736
736
 
737
737
  #[test]
738
- fn resource188_busy_and_refused_candidates_prepare_once_without_claiming() {
738
+ fn resource188_busy_reaches_claim_but_owner_refusal_does_not() {
739
739
  use crate::messaging::delivery::preparation_probe::COUNTS;
740
740
  let mut case = Case::new("188-busy");
741
741
  case.state["agents"] = serde_json::json!({"worker": {"agent_id": "worker", "status": "busy"}});
@@ -767,14 +767,15 @@ fn resource188_busy_and_refused_candidates_prepare_once_without_claiming() {
767
767
  Some("missing-team"),
768
768
  )
769
769
  .unwrap();
770
- let before = case.message_status(&busy);
771
770
  COUNTS.with(|counts| counts.set((0, 0, 0)));
772
771
  deliver_pending_messages(&case.workspace, &case.state, &transport, &case.event_log).unwrap();
773
- assert_eq!(COUNTS.with(|counts| counts.get()), (2, 2, 0));
774
- assert_eq!(case.message_status(&busy), before);
772
+ assert_eq!(COUNTS.with(|counts| counts.get()), (2, 2, 1));
773
+ assert_ne!(case.message_status(&busy), "accepted");
775
774
  assert_ne!(case.message_status(&refused), "delivered");
775
+ // This leader-only transport has no worker target. Removing idle admission
776
+ // must not also remove target binding: the claimed row still cannot inject.
776
777
  assert_eq!(transport.inject_count(), 0);
777
- assert!(case.events().contains("send.deferred_busy"));
778
+ assert!(!case.events().contains("send.deferred_busy"));
778
779
  assert!(case.events().contains("delivery.projection_refused"));
779
780
  }
780
781
 
@@ -174,6 +174,9 @@ impl Transport for DeliverOkTransport {
174
174
  fn kind(&self) -> BackendKind {
175
175
  BackendKind::Tmux
176
176
  }
177
+ fn supports_owned_native_input(&self) -> bool {
178
+ true
179
+ }
177
180
  fn spawn_first(
178
181
  &self,
179
182
  _s: &SessionName,
@@ -201,6 +204,8 @@ impl Transport for DeliverOkTransport {
201
204
  _s: Key,
202
205
  _b: bool,
203
206
  ) -> Result<InjectReport, TransportError> {
207
+ assert!(crate::transport::native_input::current_request().is_none(),
208
+ "ordinary sends must use v0.5.113 direct input, not pre-paste idle admission");
204
209
  Ok(InjectReport {
205
210
  stage_reached: crate::transport::InjectStage::Submit,
206
211
  inject_verification: crate::transport::InjectVerification::CaptureContainsToken,
@@ -344,13 +349,10 @@ fn spine_delivery_skips_queued_statuses() {
344
349
  );
345
350
  }
346
351
 
347
- // #2 (CONTRACT, corrected) — busy recipient = LIFECYCLE status=="busy" → emit ONE send.deferred_busy
348
- // {recipient,reason:"recipient_busy"} and do NOT deliver (row stays 'accepted'). golden delivery.py:491
349
- // gates on `state.agents[recipient].status == "busy"`, NOT on turn-level agent_health=WORKING.
350
- // (The prior version of this test seeded agent_health=WORKING + status="running" and asserted deferral,
351
- // misreading golden — that stale lock is the root of the deferred_busy regression; corrected here.)
352
+ // Input is unconditional: native CLIs queue messages while working. Activity
353
+ // metadata must not prevent a pending message from reaching paste + Enter.
352
354
  #[test]
353
- fn spine_delivery_busy_recipient_defers_with_event() {
355
+ fn spine_delivery_busy_recipient_is_injected_without_idle_deferral() {
354
356
  let ws = tmp_ws("deliver-busy");
355
357
  let store = store_for(&ws);
356
358
  let log = EventLog::new(&ws);
@@ -361,33 +363,30 @@ fn spine_delivery_busy_recipient_defers_with_event() {
361
363
 
362
364
  let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
363
365
 
364
- assert!(
365
- !delivered.contains(&mid),
366
- "a busy recipient's message must NOT be delivered"
367
- );
368
- assert_eq!(
369
- message_status_of(&store, &mid),
370
- "accepted",
371
- "the row must stay 'accepted' (no-drop, left for a later tick)"
372
- );
366
+ assert!(delivered.contains(&mid));
367
+ assert_eq!(message_status_of(&store, &mid), "delivered");
373
368
  let events = read_event_log(&ws);
374
369
  let deferred: Vec<_> = events
375
370
  .iter()
376
371
  .filter(|e| e.get("event").and_then(|v| v.as_str()) == Some("send.deferred_busy"))
377
372
  .collect();
378
- assert_eq!(
379
- deferred.len(),
380
- 1,
381
- "exactly one send.deferred_busy event expected; got {events:?}"
382
- );
383
- assert_eq!(
384
- deferred[0].get("recipient").and_then(|v| v.as_str()),
385
- Some("w1")
386
- );
387
- assert_eq!(
388
- deferred[0].get("reason").and_then(|v| v.as_str()),
389
- Some("recipient_busy")
390
- );
373
+ assert!(deferred.is_empty());
374
+ }
375
+
376
+ #[test]
377
+ fn spine_delivery_pi_cursor_uses_v113_direct_input_without_idle_scope() {
378
+ for provider in ["pi", "cursor_agent"] {
379
+ let ws = tmp_ws(&format!("direct-input-{provider}"));
380
+ let store = store_for(&ws);
381
+ let log = EventLog::new(&ws);
382
+ let mid = store
383
+ .create_message(Some("t"), "leader", "w1", "hi", None, true, None)
384
+ .unwrap();
385
+ let state = serde_json::json!({"agents": {"w1": {"provider": provider, "status": "running"}}});
386
+ let delivered = deliver_pending_messages(&ws, &state, &DeliverOkTransport, &log).unwrap();
387
+ assert!(delivered.contains(&mid), "{provider}");
388
+ assert_eq!(message_status_of(&store, &mid), "delivered");
389
+ }
391
390
  }
392
391
 
393
392
  // CONTRACT (shared-root, real-machine-driven; golden = correct-behavior baseline): an ALIVE worker
@@ -0,0 +1,325 @@
1
+ //! Independent watchdog effects. No native-input state-machine evidence is adopted.
2
+
3
+ use std::time::{Duration, Instant};
4
+
5
+ use sha2::{Digest, Sha256};
6
+
7
+ use super::*;
8
+ use crate::lifecycle::pane_input_lock::{acquire_required, PaneInputLockRequest};
9
+ use crate::transport::enter_rescue::{
10
+ EnterRescueContext, EnterRescueOutcome, EnterRescueProof, EnterRescueTarget,
11
+ };
12
+ use crate::transport::native_input::InputFrame;
13
+
14
+ const STABLE: Duration = Duration::from_millis(200);
15
+ const POLL: Duration = Duration::from_millis(50);
16
+ const OBSERVE: Duration = Duration::from_secs(10);
17
+
18
+ fn digest(value: &str) -> String {
19
+ format!("{:x}", Sha256::digest(value.as_bytes()))
20
+ }
21
+ fn marker_count(value: &str, marker: &str) -> usize {
22
+ value
23
+ .lines()
24
+ .map(str::trim)
25
+ .collect::<String>()
26
+ .matches(marker)
27
+ .count()
28
+ }
29
+ fn current_marker_only(context: &EnterRescueContext, frame: &InputFrame) -> bool {
30
+ let composer: String = frame.composer.lines().map(str::trim).collect();
31
+ composer.matches("[team-agent-token:").count() == 1
32
+ && composer.matches(&context.marker()).count() == 1
33
+ }
34
+
35
+ fn no_effect(reason: &'static str, reobserve: bool) -> EnterRescueOutcome {
36
+ EnterRescueOutcome::NoEffect { reason, reobserve }
37
+ }
38
+ fn refusal(reason: &str) -> TransportError {
39
+ TransportError::NativeInputNotReady {
40
+ reason: reason.into(),
41
+ }
42
+ }
43
+
44
+ fn pending(context: &EnterRescueContext, frame: &InputFrame) -> bool {
45
+ let Some(proof) = context.proof.as_ref().filter(|_| context.valid_proof()) else {
46
+ return false;
47
+ };
48
+ if frame.busy
49
+ || frame.identity != proof.ui_identity
50
+ || digest(&frame.footer) != proof.footer_sha256
51
+ {
52
+ return false;
53
+ }
54
+ let marker = context.marker();
55
+ if marker_count(&frame.transcript, &marker) != 0 {
56
+ return false;
57
+ }
58
+ (proof.visible_marker && current_marker_only(context, frame))
59
+ || proof.owned_capsule.as_ref().is_some_and(|capsule| {
60
+ capsule.valid()
61
+ && frame.capsule.as_ref() == Some(&capsule.label)
62
+ && frame.composer.trim() == capsule.label
63
+ && !frame
64
+ .transcript
65
+ .lines()
66
+ .any(|line| line.trim() == capsule.label)
67
+ })
68
+ }
69
+
70
+ fn accepted(context: &EnterRescueContext, frame: &InputFrame) -> bool {
71
+ let Some(proof) = context.proof.as_ref().filter(|_| context.valid_proof()) else {
72
+ return false;
73
+ };
74
+ // Invisible whitespace is not an empty composer receipt.
75
+ if !frame.composer.is_empty() || frame.identity != proof.ui_identity {
76
+ return false;
77
+ }
78
+ if proof.visible_marker
79
+ && proof.transcript_marker_absent
80
+ && marker_count(&frame.transcript, &context.marker()) == 1
81
+ {
82
+ return true;
83
+ }
84
+ proof.owned_capsule.as_ref().is_some_and(|capsule| {
85
+ capsule.valid()
86
+ && frame
87
+ .transcript
88
+ .lines()
89
+ .filter(|line| line.trim() == capsule.label)
90
+ .count()
91
+ == 1
92
+ })
93
+ }
94
+
95
+ fn same_editor(left: &InputFrame, right: &InputFrame) -> bool {
96
+ left.composer == right.composer
97
+ && left.identity == right.identity
98
+ && left.footer == right.footer
99
+ && left.capsule == right.capsule
100
+ && left.busy == right.busy
101
+ }
102
+
103
+ impl TmuxBackend {
104
+ fn rescue_binding(
105
+ &self,
106
+ selected: &EnterRescueTarget,
107
+ allow_unset_nonce: bool,
108
+ ) -> Result<EnterRescueTarget, TransportError> {
109
+ let argv = self.tmux_argv(&[
110
+ "tmux".into(), "display-message".into(), "-p".into(), "-t".into(), selected.pane_id.clone(),
111
+ "#{socket_path}|#{pane_id}|#{pane_pid}|#{session_name}|#{window_name}|#{@team_agent_pane_binding_nonce}|#{pane_in_mode}|#{pane_height}".into(),
112
+ ]);
113
+ let output = self.runner.run(&argv)?;
114
+ if !output.success || output.stdout.lines().count() != 1 {
115
+ return Err(refusal("rescue_binding_query"));
116
+ }
117
+ let fields: Vec<_> = output.stdout.trim_end().split('|').collect();
118
+ if fields.len() != 8
119
+ || !std::path::Path::new(fields[0]).is_absolute()
120
+ || fields[1] != selected.pane_id
121
+ || fields[3].is_empty()
122
+ || fields[4].is_empty()
123
+ || (fields[5].is_empty() && !allow_unset_nonce)
124
+ || fields[6] != "0"
125
+ {
126
+ return Err(refusal("rescue_binding_invalid"));
127
+ }
128
+ let pid = fields[2]
129
+ .parse::<u32>()
130
+ .map_err(|_| refusal("rescue_binding_pid"))?;
131
+ let height = fields[7]
132
+ .parse::<u32>()
133
+ .map_err(|_| refusal("rescue_binding_height"))?;
134
+ if pid <= 1 || height < 10 {
135
+ return Err(refusal("rescue_binding_invalid"));
136
+ }
137
+ let actual = EnterRescueTarget {
138
+ provider: selected.provider,
139
+ endpoint: fields[0].into(),
140
+ pane_id: fields[1].into(),
141
+ pane_pid: pid,
142
+ pane_height: height,
143
+ carrier_birth: team_agent_contract::host::process::process_birth_identity(pid)
144
+ .map_err(|_| refusal("rescue_carrier_birth"))?,
145
+ binding_nonce: fields[5].into(),
146
+ session: fields[3].into(),
147
+ window: fields[4].into(),
148
+ };
149
+ if !actual.same_selection(selected) {
150
+ return Err(refusal("rescue_selection_changed"));
151
+ }
152
+ Ok(actual)
153
+ }
154
+
155
+ fn rescue_frame(
156
+ &self,
157
+ expected: &EnterRescueTarget,
158
+ ) -> Result<Option<InputFrame>, TransportError> {
159
+ if self.rescue_binding(expected, false)? != *expected {
160
+ return Err(refusal("rescue_binding_changed"));
161
+ }
162
+ let frame = match self.observe_native_input(
163
+ &Target::Pane(PaneId::new(&expected.pane_id)),
164
+ expected.provider,
165
+ ) {
166
+ Ok(frame) => frame,
167
+ Err(TransportError::NativeInputNotReady { reason })
168
+ if reason == "native_surface_unknown" =>
169
+ {
170
+ None
171
+ }
172
+ Err(error) => return Err(error),
173
+ };
174
+ if self.rescue_binding(expected, false)? != *expected {
175
+ return Err(refusal("rescue_binding_changed"));
176
+ }
177
+ Ok(frame)
178
+ }
179
+
180
+ /// Same original delivery boundary only. No late snapshot is taken by fire.
181
+ pub(super) fn snapshot_rescue_context(
182
+ &self,
183
+ target: &Target,
184
+ context: &EnterRescueContext,
185
+ rendered_payload: &str,
186
+ ) -> Result<EnterRescueContext, TransportError> {
187
+ let mut result = context.clone();
188
+ if !context.valid_message() || digest(rendered_payload) != context.payload_sha256 {
189
+ return Ok(result);
190
+ }
191
+ let Some(selected) = context.target.as_ref() else {
192
+ return Ok(result);
193
+ };
194
+ if pane_from_target(target).as_str() != selected.pane_id {
195
+ return Ok(result);
196
+ }
197
+ let Some(_lock) = acquire_required(PaneInputLockRequest {
198
+ workspace: self.event_workspace.as_deref(),
199
+ target_key: &selected.pane_id,
200
+ operation: "enter_rescue_snapshot",
201
+ }) else {
202
+ return Ok(result);
203
+ };
204
+ let initial = match self.rescue_binding(selected, true) {
205
+ Ok(value) => value,
206
+ Err(_) => return Ok(result),
207
+ };
208
+ if (!selected.endpoint.is_empty() && initial.endpoint != selected.endpoint)
209
+ || (!selected.carrier_birth.is_empty()
210
+ && initial.carrier_birth != selected.carrier_birth)
211
+ || (!selected.binding_nonce.is_empty()
212
+ && initial.binding_nonce != selected.binding_nonce)
213
+ {
214
+ return Ok(result);
215
+ }
216
+ let frame = match self.observe_native_input(target, selected.provider) {
217
+ Ok(Some(frame)) => frame,
218
+ _ => return Ok(result),
219
+ };
220
+ if !current_marker_only(context, &frame)
221
+ || marker_count(&frame.transcript, &context.marker()) != 0
222
+ {
223
+ return Ok(result);
224
+ }
225
+ if self.rescue_binding(selected, true)? != initial {
226
+ return Ok(result);
227
+ }
228
+ let mut expected = initial;
229
+ if expected.binding_nonce.is_empty() {
230
+ let Some(workspace) = self.event_workspace.as_deref() else {
231
+ return Ok(result);
232
+ };
233
+ let pane = PaneId::new(&expected.pane_id);
234
+ let proposed = next_pane_binding_nonce(workspace, &expected.endpoint, &pane);
235
+ expected.binding_nonce = self.set_pane_binding_nonce_if_unset(&pane, &proposed)?;
236
+ }
237
+ if self.rescue_binding(selected, false)? != expected {
238
+ return Ok(result);
239
+ }
240
+ result.target = Some(expected);
241
+ result.proof = Some(EnterRescueProof {
242
+ ui_identity: frame.identity,
243
+ footer_sha256: digest(&frame.footer),
244
+ visible_marker: true,
245
+ transcript_marker_absent: true,
246
+ owned_capsule: None,
247
+ });
248
+ Ok(result)
249
+ }
250
+
251
+ pub(super) fn rescue_guarded_enter(
252
+ &self,
253
+ context: &EnterRescueContext,
254
+ authorize: &dyn Fn() -> bool,
255
+ observer: Option<&dyn SubmitObserver>,
256
+ ) -> Result<EnterRescueOutcome, TransportError> {
257
+ let Some(expected) = context.target.as_ref().filter(|target| target.bound()) else {
258
+ return Ok(no_effect("binding_missing", false));
259
+ };
260
+ if !context.valid_message() || !context.valid_proof() {
261
+ return Ok(no_effect("proof_missing", false));
262
+ }
263
+ let Some(_lock) = acquire_required(PaneInputLockRequest {
264
+ workspace: self.event_workspace.as_deref(),
265
+ target_key: &expected.pane_id,
266
+ operation: "enter_rescue",
267
+ }) else {
268
+ return Ok(no_effect("input_lock", true));
269
+ };
270
+ let Some(first) = self.rescue_frame(expected)? else {
271
+ return Ok(no_effect("surface_unknown", true));
272
+ };
273
+ if accepted(context, &first) {
274
+ return Ok(if authorize() {
275
+ EnterRescueOutcome::Consumed
276
+ } else {
277
+ no_effect("canonical_changed", false)
278
+ });
279
+ }
280
+ if first.busy {
281
+ return Ok(no_effect("busy", true));
282
+ }
283
+ if !pending(context, &first) {
284
+ return Ok(no_effect("residue_mismatch", false));
285
+ }
286
+ std::thread::sleep(STABLE);
287
+ let Some(stable) = self.rescue_frame(expected)? else {
288
+ return Ok(no_effect("surface_unknown", true));
289
+ };
290
+ if !same_editor(&first, &stable) || !pending(context, &stable) {
291
+ return Ok(no_effect("composer_changed", false));
292
+ }
293
+ // Last fresh current-message/busy/binding checks are under the same lock.
294
+ let Some(last) = self.rescue_frame(expected)? else {
295
+ return Ok(no_effect("surface_unknown", true));
296
+ };
297
+ if !same_editor(&stable, &last) || !pending(context, &last) {
298
+ return Ok(no_effect("composer_changed", false));
299
+ }
300
+ if !authorize() {
301
+ return Ok(no_effect("canonical_changed", false));
302
+ }
303
+ self.run_inject_stage(
304
+ &tmux_send_submit_argv(&PaneId::new(&expected.pane_id), Key::Enter),
305
+ InjectStage::Submit,
306
+ )?;
307
+ notify_submit_observer(observer);
308
+ // Any failure after the key is uncertain, not a retryable no-effect.
309
+ let start = Instant::now();
310
+ while start.elapsed() < OBSERVE {
311
+ match self.rescue_frame(expected) {
312
+ Ok(Some(frame)) if accepted(context, &frame) => {
313
+ return Ok(EnterRescueOutcome::Entered { accepted: true })
314
+ }
315
+ Err(_) => break,
316
+ _ => std::thread::sleep(POLL),
317
+ }
318
+ }
319
+ Ok(EnterRescueOutcome::Entered { accepted: false })
320
+ }
321
+ }
322
+
323
+ #[cfg(test)]
324
+ #[path = "enter_rescue_tests.rs"]
325
+ mod tests;