@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +20 -0
- package/crates/team-agent/src/db/message_store.rs +10 -3
- package/crates/team-agent/src/leader/start.rs +27 -47
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +2 -7
- package/crates/team-agent/src/messaging/delivery.rs +157 -136
- package/crates/team-agent/src/messaging/delivery_consumption_tests.rs +223 -0
- package/crates/team-agent/src/messaging/enter_rescue.rs +182 -154
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +344 -277
- package/crates/team-agent/src/messaging/mod.rs +1 -1
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +6 -5
- package/crates/team-agent/src/messaging/tests/spine.rs +27 -28
- package/crates/team-agent/src/tmux_backend/enter_rescue.rs +325 -0
- package/crates/team-agent/src/tmux_backend/enter_rescue_tests.rs +432 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +19 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +141 -4
- package/crates/team-agent/src/tmux_backend.rs +27 -0
- package/crates/team-agent/src/transport/enter_rescue.rs +166 -0
- package/crates/team-agent/src/transport/native_input.rs +136 -10
- package/crates/team-agent/src/transport/test_support.rs +25 -0
- package/crates/team-agent/src/transport.rs +34 -0
- package/package.json +4 -4
|
@@ -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
|
|
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,
|
|
774
|
-
|
|
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
|
-
//
|
|
348
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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;
|