@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/leader/lease.rs +1 -18
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- package/crates/team-agent/src/messaging/delivery.rs +132 -27
- 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/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 +781 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +594 -0
- package/crates/team-agent/src/transport.rs +19 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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(
|
|
@@ -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
|
}
|
|
@@ -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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
|
2849
|
-
self.workspace,
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
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,
|