@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/mod.rs +20 -0
- package/crates/team-agent/src/leader/lease.rs +1 -18
- package/crates/team-agent/src/leader/start.rs +27 -47
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +2 -7
- package/crates/team-agent/src/messaging/delivery.rs +102 -69
- package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/mod.rs +1 -0
- package/crates/team-agent/src/messaging/scheduler.rs +8 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
- 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/messaging/types.rs +1 -0
- package/crates/team-agent/src/state/repository.rs +47 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +918 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +716 -0
- package/crates/team-agent/src/transport.rs +19 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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(&
|
|
631
|
+
let observed = Path::new(&authority.endpoint)
|
|
646
632
|
.canonicalize()
|
|
647
|
-
.unwrap_or_else(|_| PathBuf::from(&
|
|
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
|
|
661
|
-
return
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
644
|
+
if allow_nested_attach {
|
|
645
|
+
return Ok((
|
|
646
|
+
Some(authority),
|
|
647
|
+
Some(ManagedClientAttachMode::AttachSession),
|
|
648
|
+
false,
|
|
649
|
+
));
|
|
666
650
|
}
|
|
667
|
-
|
|
668
|
-
|
|
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}
|
|
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":
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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()
|
|
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
|
-
///
|
|
2276
|
-
///
|
|
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
|
|
2849
|
-
self.workspace,
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
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,
|