@team-agent/installer 0.5.63 → 0.5.65
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/adapters.rs +5 -0
- package/crates/team-agent/src/cli/emit.rs +15 -0
- package/crates/team-agent/src/cli/mod.rs +9 -8
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/tests/mod.rs +31 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +13 -3
- package/crates/team-agent/src/cli/types.rs +7 -0
- package/crates/team-agent/src/coordinator/health.rs +17 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +55 -2
- package/crates/team-agent/src/db/migration.rs +2 -1
- package/crates/team-agent/src/db/schema.rs +16 -8
- package/crates/team-agent/src/leader/lease.rs +1 -2
- package/crates/team-agent/src/leader/start.rs +8 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +51 -9
- package/crates/team-agent/src/lifecycle/restart/common.rs +3 -3
- package/crates/team-agent/src/lifecycle/restart/remove.rs +7 -139
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +148 -5
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +45 -13
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +10 -6
- package/crates/team-agent/src/lifecycle/tests.rs +11 -5
- package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/delivery.rs +29 -0
- package/crates/team-agent/src/messaging/helpers.rs +2 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +12 -0
- package/crates/team-agent/src/messaging/mod.rs +2 -0
- package/crates/team-agent/src/messaging/results.rs +5 -0
- package/crates/team-agent/src/messaging/send.rs +9 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +32 -0
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/wait.rs +366 -0
- package/crates/team-agent/src/messaging/watchers.rs +153 -6
- package/crates/team-agent/src/tmux_backend.rs +16 -12
- package/crates/team-agent/src/transport.rs +11 -11
- package/package.json +4 -4
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
use std::path::Path;
|
|
2
|
+
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
|
|
5
|
+
use super::MessagingError;
|
|
6
|
+
|
|
7
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
8
|
+
pub struct WaitResult {
|
|
9
|
+
pub task_id: String,
|
|
10
|
+
pub result_id: String,
|
|
11
|
+
pub waited: bool,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl WaitResult {
|
|
15
|
+
pub fn to_json(&self) -> Value {
|
|
16
|
+
serde_json::json!({
|
|
17
|
+
"ok": true,
|
|
18
|
+
"status": "completed",
|
|
19
|
+
"task_id": self.task_id,
|
|
20
|
+
"result_id": self.result_id,
|
|
21
|
+
"waited": self.waited,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[cfg(not(unix))]
|
|
27
|
+
pub fn wait_for_result(_workspace: &Path, _task_id: &str) -> Result<WaitResult, MessagingError> {
|
|
28
|
+
Err(MessagingError::Validation(
|
|
29
|
+
"wait --task requires POSIX FIFO support".to_string(),
|
|
30
|
+
))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[cfg(unix)]
|
|
34
|
+
mod unix {
|
|
35
|
+
use std::ffi::CString;
|
|
36
|
+
use std::fs::{File, OpenOptions};
|
|
37
|
+
use std::io::{self, Read};
|
|
38
|
+
use std::os::fd::{AsRawFd, RawFd};
|
|
39
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
40
|
+
use std::path::{Path, PathBuf};
|
|
41
|
+
use std::sync::atomic::{AtomicI32, Ordering};
|
|
42
|
+
|
|
43
|
+
use rusqlite::{params, OptionalExtension};
|
|
44
|
+
|
|
45
|
+
use crate::event_log::EventLog;
|
|
46
|
+
use crate::message_store::MessageStore;
|
|
47
|
+
|
|
48
|
+
use super::{MessagingError, WaitResult};
|
|
49
|
+
|
|
50
|
+
static CAUGHT_SIGNAL: AtomicI32 = AtomicI32::new(0);
|
|
51
|
+
|
|
52
|
+
enum Registration {
|
|
53
|
+
Completed(String),
|
|
54
|
+
Pending,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
struct FifoEndpoints {
|
|
58
|
+
path: PathBuf,
|
|
59
|
+
reader: File,
|
|
60
|
+
_keepalive_writer: File,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
impl Drop for FifoEndpoints {
|
|
64
|
+
fn drop(&mut self) {
|
|
65
|
+
let _ = std::fs::remove_file(&self.path);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
struct RegistrationCleanup {
|
|
70
|
+
db_path: PathBuf,
|
|
71
|
+
watcher_id: String,
|
|
72
|
+
active: bool,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl RegistrationCleanup {
|
|
76
|
+
fn cleanup(&mut self) -> Result<(), MessagingError> {
|
|
77
|
+
if self.active {
|
|
78
|
+
let conn = crate::db::schema::open_db(&self.db_path)?;
|
|
79
|
+
conn.execute(
|
|
80
|
+
"delete from result_watchers where watcher_id = ?1",
|
|
81
|
+
params![self.watcher_id],
|
|
82
|
+
)?;
|
|
83
|
+
self.active = false;
|
|
84
|
+
}
|
|
85
|
+
Ok(())
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
impl Drop for RegistrationCleanup {
|
|
90
|
+
fn drop(&mut self) {
|
|
91
|
+
if self.active {
|
|
92
|
+
if let Ok(conn) = crate::db::schema::open_db(&self.db_path) {
|
|
93
|
+
let _ = conn.execute(
|
|
94
|
+
"delete from result_watchers where watcher_id = ?1",
|
|
95
|
+
params![self.watcher_id],
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
struct SignalGuard {
|
|
103
|
+
old_int: libc::sigaction,
|
|
104
|
+
old_term: libc::sigaction,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
impl SignalGuard {
|
|
108
|
+
fn install() -> io::Result<Self> {
|
|
109
|
+
CAUGHT_SIGNAL.store(0, Ordering::SeqCst);
|
|
110
|
+
let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
|
|
111
|
+
action.sa_sigaction = record_signal as *const () as libc::sighandler_t;
|
|
112
|
+
action.sa_flags = 0;
|
|
113
|
+
unsafe {
|
|
114
|
+
libc::sigemptyset(&mut action.sa_mask);
|
|
115
|
+
}
|
|
116
|
+
let mut old_int: libc::sigaction = unsafe { std::mem::zeroed() };
|
|
117
|
+
let mut old_term: libc::sigaction = unsafe { std::mem::zeroed() };
|
|
118
|
+
if unsafe { libc::sigaction(libc::SIGINT, &action, &mut old_int) } != 0 {
|
|
119
|
+
return Err(io::Error::last_os_error());
|
|
120
|
+
}
|
|
121
|
+
if unsafe { libc::sigaction(libc::SIGTERM, &action, &mut old_term) } != 0 {
|
|
122
|
+
let error = io::Error::last_os_error();
|
|
123
|
+
unsafe {
|
|
124
|
+
libc::sigaction(libc::SIGINT, &old_int, std::ptr::null_mut());
|
|
125
|
+
}
|
|
126
|
+
return Err(error);
|
|
127
|
+
}
|
|
128
|
+
Ok(Self { old_int, old_term })
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
impl Drop for SignalGuard {
|
|
133
|
+
fn drop(&mut self) {
|
|
134
|
+
unsafe {
|
|
135
|
+
libc::sigaction(libc::SIGINT, &self.old_int, std::ptr::null_mut());
|
|
136
|
+
libc::sigaction(libc::SIGTERM, &self.old_term, std::ptr::null_mut());
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
extern "C" fn record_signal(signal: libc::c_int) {
|
|
142
|
+
CAUGHT_SIGNAL.store(signal, Ordering::SeqCst);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
pub fn wait_for_result(workspace: &Path, task_id: &str) -> Result<WaitResult, MessagingError> {
|
|
146
|
+
if task_id.trim().is_empty() {
|
|
147
|
+
return Err(MessagingError::Validation(
|
|
148
|
+
"wait --task requires a non-empty task id".to_string(),
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
let _signals = SignalGuard::install()?;
|
|
152
|
+
let store = MessageStore::open(workspace)?;
|
|
153
|
+
let workspace = std::fs::canonicalize(workspace)?;
|
|
154
|
+
let watcher_id = format!("wait-{}", random_suffix()?);
|
|
155
|
+
let fifo_path = fifo_path(&workspace, task_id, &watcher_id)?;
|
|
156
|
+
let fifo = create_fifo(&fifo_path)?;
|
|
157
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
158
|
+
let registration = register(&conn, &watcher_id, task_id, &fifo_path)?;
|
|
159
|
+
if let Registration::Completed(result_id) = registration {
|
|
160
|
+
return Ok(WaitResult {
|
|
161
|
+
task_id: task_id.to_string(),
|
|
162
|
+
result_id,
|
|
163
|
+
waited: false,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let mut cleanup = RegistrationCleanup {
|
|
168
|
+
db_path: store.db_path().to_path_buf(),
|
|
169
|
+
watcher_id: watcher_id.clone(),
|
|
170
|
+
active: true,
|
|
171
|
+
};
|
|
172
|
+
EventLog::new(&workspace).write(
|
|
173
|
+
"result_wake.registered",
|
|
174
|
+
serde_json::json!({
|
|
175
|
+
"task_id": task_id,
|
|
176
|
+
"watcher_id": watcher_id,
|
|
177
|
+
"recipient": fifo_path,
|
|
178
|
+
}),
|
|
179
|
+
)?;
|
|
180
|
+
let result_id = read_wake_line(fifo.reader.as_raw_fd())?;
|
|
181
|
+
cleanup.cleanup()?;
|
|
182
|
+
Ok(WaitResult {
|
|
183
|
+
task_id: task_id.to_string(),
|
|
184
|
+
result_id,
|
|
185
|
+
waited: true,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
fn register(
|
|
190
|
+
conn: &rusqlite::Connection,
|
|
191
|
+
watcher_id: &str,
|
|
192
|
+
task_id: &str,
|
|
193
|
+
fifo_path: &Path,
|
|
194
|
+
) -> Result<Registration, MessagingError> {
|
|
195
|
+
conn.execute_batch("BEGIN IMMEDIATE")?;
|
|
196
|
+
let operation = (|| -> Result<Registration, MessagingError> {
|
|
197
|
+
let completed = conn
|
|
198
|
+
.query_row(
|
|
199
|
+
"select result_id from results
|
|
200
|
+
where task_id = ?1 order by created_at, result_id limit 1",
|
|
201
|
+
params![task_id],
|
|
202
|
+
|row| row.get::<_, String>(0),
|
|
203
|
+
)
|
|
204
|
+
.optional()?;
|
|
205
|
+
if let Some(result_id) = completed {
|
|
206
|
+
return Ok(Registration::Completed(result_id));
|
|
207
|
+
}
|
|
208
|
+
conn.execute(
|
|
209
|
+
"insert into result_watchers(
|
|
210
|
+
watcher_id, owner_team_id, task_id, agent_id, message_id,
|
|
211
|
+
leader_id, recipient, status, created_at
|
|
212
|
+
) values (?1, null, ?2, null, null, 'team-agent', ?3, 'pending', ?4)",
|
|
213
|
+
params![
|
|
214
|
+
watcher_id,
|
|
215
|
+
task_id,
|
|
216
|
+
fifo_path.to_string_lossy().as_ref(),
|
|
217
|
+
chrono::Utc::now().to_rfc3339(),
|
|
218
|
+
],
|
|
219
|
+
)?;
|
|
220
|
+
Ok(Registration::Pending)
|
|
221
|
+
})();
|
|
222
|
+
match operation {
|
|
223
|
+
Ok(registration) => match conn.execute_batch("COMMIT") {
|
|
224
|
+
Ok(()) => Ok(registration),
|
|
225
|
+
Err(error) => {
|
|
226
|
+
let _ = conn.execute_batch("ROLLBACK");
|
|
227
|
+
Err(error.into())
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
Err(error) => {
|
|
231
|
+
let _ = conn.execute_batch("ROLLBACK");
|
|
232
|
+
Err(error)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn fifo_path(
|
|
238
|
+
workspace: &Path,
|
|
239
|
+
task_id: &str,
|
|
240
|
+
watcher_id: &str,
|
|
241
|
+
) -> Result<PathBuf, MessagingError> {
|
|
242
|
+
let dir = workspace.join(".team/runtime/wake");
|
|
243
|
+
std::fs::create_dir_all(&dir)?;
|
|
244
|
+
let task = task_id
|
|
245
|
+
.chars()
|
|
246
|
+
.map(|ch| {
|
|
247
|
+
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
|
|
248
|
+
ch
|
|
249
|
+
} else {
|
|
250
|
+
'_'
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
.take(80)
|
|
254
|
+
.collect::<String>();
|
|
255
|
+
let task = if task.is_empty() { "task" } else { &task };
|
|
256
|
+
Ok(dir.join(format!("{task}-{watcher_id}.fifo")))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fn random_suffix() -> Result<String, MessagingError> {
|
|
260
|
+
let mut bytes = [0_u8; 16];
|
|
261
|
+
File::open("/dev/urandom")?.read_exact(&mut bytes)?;
|
|
262
|
+
Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
fn create_fifo(path: &Path) -> Result<FifoEndpoints, MessagingError> {
|
|
266
|
+
let c_path = CString::new(path.as_os_str().as_encoded_bytes())
|
|
267
|
+
.map_err(|_| MessagingError::Validation("FIFO path contains a NUL byte".to_string()))?;
|
|
268
|
+
if unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) } != 0 {
|
|
269
|
+
return Err(io::Error::last_os_error().into());
|
|
270
|
+
}
|
|
271
|
+
let reader = match OpenOptions::new()
|
|
272
|
+
.read(true)
|
|
273
|
+
.custom_flags(libc::O_NONBLOCK)
|
|
274
|
+
.open(path)
|
|
275
|
+
{
|
|
276
|
+
Ok(reader) => reader,
|
|
277
|
+
Err(error) => {
|
|
278
|
+
let _ = std::fs::remove_file(path);
|
|
279
|
+
return Err(error.into());
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
let keepalive_writer = match OpenOptions::new()
|
|
283
|
+
.write(true)
|
|
284
|
+
.custom_flags(libc::O_NONBLOCK)
|
|
285
|
+
.open(path)
|
|
286
|
+
{
|
|
287
|
+
Ok(writer) => writer,
|
|
288
|
+
Err(error) => {
|
|
289
|
+
let _ = std::fs::remove_file(path);
|
|
290
|
+
return Err(error.into());
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
set_blocking(reader.as_raw_fd())?;
|
|
294
|
+
Ok(FifoEndpoints {
|
|
295
|
+
path: path.to_path_buf(),
|
|
296
|
+
reader,
|
|
297
|
+
_keepalive_writer: keepalive_writer,
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn set_blocking(fd: RawFd) -> io::Result<()> {
|
|
302
|
+
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
|
303
|
+
if flags < 0 {
|
|
304
|
+
return Err(io::Error::last_os_error());
|
|
305
|
+
}
|
|
306
|
+
if unsafe { libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK) } < 0 {
|
|
307
|
+
return Err(io::Error::last_os_error());
|
|
308
|
+
}
|
|
309
|
+
Ok(())
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
fn read_wake_line(fd: RawFd) -> Result<String, MessagingError> {
|
|
313
|
+
let mut line = Vec::new();
|
|
314
|
+
loop {
|
|
315
|
+
if let Some(signal) = caught_signal() {
|
|
316
|
+
return Err(interrupted(signal).into());
|
|
317
|
+
}
|
|
318
|
+
let mut bytes = [0_u8; 256];
|
|
319
|
+
let count =
|
|
320
|
+
unsafe { libc::read(fd, bytes.as_mut_ptr().cast::<libc::c_void>(), bytes.len()) };
|
|
321
|
+
if count > 0 {
|
|
322
|
+
line.extend_from_slice(&bytes[..count as usize]);
|
|
323
|
+
if let Some(newline) = line.iter().position(|byte| *byte == b'\n') {
|
|
324
|
+
line.truncate(newline);
|
|
325
|
+
let result_id = String::from_utf8(line).map_err(|error| {
|
|
326
|
+
MessagingError::Validation(format!("invalid FIFO wake line: {error}"))
|
|
327
|
+
})?;
|
|
328
|
+
let result_id = result_id.trim().to_string();
|
|
329
|
+
if result_id.is_empty() {
|
|
330
|
+
return Err(MessagingError::Validation(
|
|
331
|
+
"FIFO wake line did not contain a result id".to_string(),
|
|
332
|
+
));
|
|
333
|
+
}
|
|
334
|
+
return Ok(result_id);
|
|
335
|
+
}
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if count == 0 {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
let error = io::Error::last_os_error();
|
|
342
|
+
if error.kind() == io::ErrorKind::Interrupted {
|
|
343
|
+
if let Some(signal) = caught_signal() {
|
|
344
|
+
return Err(interrupted(signal).into());
|
|
345
|
+
}
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
return Err(error.into());
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
fn caught_signal() -> Option<i32> {
|
|
353
|
+
let signal = CAUGHT_SIGNAL.load(Ordering::SeqCst);
|
|
354
|
+
(signal != 0).then_some(signal)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
fn interrupted(signal: i32) -> io::Error {
|
|
358
|
+
io::Error::new(
|
|
359
|
+
io::ErrorKind::Interrupted,
|
|
360
|
+
format!("wait interrupted by signal {signal}"),
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[cfg(unix)]
|
|
366
|
+
pub use unix::wait_for_result;
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
use std::path::Path;
|
|
4
4
|
|
|
5
|
+
#[cfg(unix)]
|
|
6
|
+
use std::fs::OpenOptions;
|
|
7
|
+
#[cfg(unix)]
|
|
8
|
+
use std::io::Write as _;
|
|
9
|
+
#[cfg(unix)]
|
|
10
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
11
|
+
|
|
5
12
|
use rusqlite::{params, OptionalExtension};
|
|
6
13
|
|
|
7
14
|
use crate::event_log::EventLog;
|
|
@@ -119,6 +126,128 @@ fn watcher_matches(
|
|
|
119
126
|
task_matches && agent_matches
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
pub(crate) fn notify_fifo_result_watchers(
|
|
130
|
+
conn: &rusqlite::Connection,
|
|
131
|
+
event_log: &EventLog,
|
|
132
|
+
task_id: &str,
|
|
133
|
+
result_id: &str,
|
|
134
|
+
) -> Result<(), MessagingError> {
|
|
135
|
+
let mut stmt = conn.prepare(
|
|
136
|
+
"select watcher_id, recipient from result_watchers
|
|
137
|
+
where task_id = ?1 and status = 'pending' and recipient is not null
|
|
138
|
+
order by created_at, watcher_id",
|
|
139
|
+
)?;
|
|
140
|
+
let rows = stmt
|
|
141
|
+
.query_map(params![task_id], |row| {
|
|
142
|
+
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
|
143
|
+
})?
|
|
144
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
145
|
+
drop(stmt);
|
|
146
|
+
for (watcher_id, recipient) in rows {
|
|
147
|
+
let _ = notify_fifo_waiter(conn, event_log, &watcher_id, task_id, result_id, &recipient)?;
|
|
148
|
+
}
|
|
149
|
+
Ok(())
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[cfg(unix)]
|
|
153
|
+
fn notify_fifo_waiter(
|
|
154
|
+
conn: &rusqlite::Connection,
|
|
155
|
+
event_log: &EventLog,
|
|
156
|
+
watcher_id: &str,
|
|
157
|
+
task_id: &str,
|
|
158
|
+
result_id: &str,
|
|
159
|
+
recipient: &str,
|
|
160
|
+
) -> Result<WatcherNotice, MessagingError> {
|
|
161
|
+
let write = (|| -> std::io::Result<()> {
|
|
162
|
+
let mut fifo = OpenOptions::new()
|
|
163
|
+
.write(true)
|
|
164
|
+
.custom_flags(libc::O_NONBLOCK)
|
|
165
|
+
.open(recipient)?;
|
|
166
|
+
fifo.write_all(format!("{result_id}\n").as_bytes())
|
|
167
|
+
})();
|
|
168
|
+
match write {
|
|
169
|
+
Ok(()) => {
|
|
170
|
+
event_log.write(
|
|
171
|
+
"result_wake.notified",
|
|
172
|
+
serde_json::json!({
|
|
173
|
+
"task_id": task_id,
|
|
174
|
+
"result_id": result_id,
|
|
175
|
+
"watcher_id": watcher_id,
|
|
176
|
+
"recipient": recipient,
|
|
177
|
+
}),
|
|
178
|
+
)?;
|
|
179
|
+
Ok(WatcherNotice {
|
|
180
|
+
watcher_id: watcher_id.to_string(),
|
|
181
|
+
result_id: Some(result_id.to_string()),
|
|
182
|
+
ok: true,
|
|
183
|
+
status: Some("notified".to_string()),
|
|
184
|
+
notified_message_id: None,
|
|
185
|
+
primary_watcher_id: None,
|
|
186
|
+
prior_state: None,
|
|
187
|
+
error: None,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
Err(error)
|
|
191
|
+
if matches!(
|
|
192
|
+
error.raw_os_error(),
|
|
193
|
+
Some(code) if code == libc::ENXIO || code == libc::ENOENT
|
|
194
|
+
) =>
|
|
195
|
+
{
|
|
196
|
+
let reason = if error.raw_os_error() == Some(libc::ENXIO) {
|
|
197
|
+
"ENXIO"
|
|
198
|
+
} else {
|
|
199
|
+
"ENOENT"
|
|
200
|
+
};
|
|
201
|
+
conn.execute(
|
|
202
|
+
"delete from result_watchers where watcher_id = ?1",
|
|
203
|
+
params![watcher_id],
|
|
204
|
+
)?;
|
|
205
|
+
event_log.write(
|
|
206
|
+
"result_wake.notify_failed",
|
|
207
|
+
serde_json::json!({
|
|
208
|
+
"task_id": task_id,
|
|
209
|
+
"result_id": result_id,
|
|
210
|
+
"watcher_id": watcher_id,
|
|
211
|
+
"recipient": recipient,
|
|
212
|
+
"reason": reason,
|
|
213
|
+
}),
|
|
214
|
+
)?;
|
|
215
|
+
Ok(WatcherNotice {
|
|
216
|
+
watcher_id: watcher_id.to_string(),
|
|
217
|
+
result_id: Some(result_id.to_string()),
|
|
218
|
+
ok: false,
|
|
219
|
+
status: Some("removed".to_string()),
|
|
220
|
+
notified_message_id: None,
|
|
221
|
+
primary_watcher_id: None,
|
|
222
|
+
prior_state: None,
|
|
223
|
+
error: Some(reason.to_string()),
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
Err(error) => Err(error.into()),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#[cfg(not(unix))]
|
|
231
|
+
fn notify_fifo_waiter(
|
|
232
|
+
_conn: &rusqlite::Connection,
|
|
233
|
+
_event_log: &EventLog,
|
|
234
|
+
watcher_id: &str,
|
|
235
|
+
_task_id: &str,
|
|
236
|
+
result_id: &str,
|
|
237
|
+
_recipient: &str,
|
|
238
|
+
) -> Result<WatcherNotice, MessagingError> {
|
|
239
|
+
Ok(WatcherNotice {
|
|
240
|
+
watcher_id: watcher_id.to_string(),
|
|
241
|
+
result_id: Some(result_id.to_string()),
|
|
242
|
+
ok: false,
|
|
243
|
+
status: Some("unsupported".to_string()),
|
|
244
|
+
notified_message_id: None,
|
|
245
|
+
primary_watcher_id: None,
|
|
246
|
+
prior_state: None,
|
|
247
|
+
error: Some("POSIX FIFO support unavailable".to_string()),
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
122
251
|
fn deliver_primary_watcher(
|
|
123
252
|
workspace: &Path,
|
|
124
253
|
conn: &rusqlite::Connection,
|
|
@@ -143,6 +272,20 @@ fn deliver_primary_watcher(
|
|
|
143
272
|
"missing_result_id",
|
|
144
273
|
);
|
|
145
274
|
};
|
|
275
|
+
if let Some(recipient) = watcher
|
|
276
|
+
.get("recipient")
|
|
277
|
+
.and_then(|value| value.as_str())
|
|
278
|
+
.filter(|recipient| Path::new(recipient).is_absolute())
|
|
279
|
+
{
|
|
280
|
+
return notify_fifo_waiter(
|
|
281
|
+
conn,
|
|
282
|
+
event_log,
|
|
283
|
+
watcher_id,
|
|
284
|
+
result_task.unwrap_or_default(),
|
|
285
|
+
result_id,
|
|
286
|
+
recipient,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
146
289
|
if let Some(existing) = delivered_result_message(
|
|
147
290
|
store,
|
|
148
291
|
result_id,
|
|
@@ -196,7 +339,10 @@ fn deliver_primary_watcher(
|
|
|
196
339
|
.get("leader_id")
|
|
197
340
|
.and_then(|v| v.as_str())
|
|
198
341
|
.unwrap_or("team-agent"),
|
|
199
|
-
|
|
342
|
+
watcher
|
|
343
|
+
.get("recipient")
|
|
344
|
+
.and_then(|value| value.as_str())
|
|
345
|
+
.unwrap_or("leader"),
|
|
200
346
|
&content,
|
|
201
347
|
None,
|
|
202
348
|
false,
|
|
@@ -332,7 +478,7 @@ pub fn retry_result_deliveries(
|
|
|
332
478
|
// delivery path with dedupe/attempt bounds); a watcher is never flipped to
|
|
333
479
|
// `notified` without a delivery. Missing result rows are skipped (still retryable).
|
|
334
480
|
let mut stmt = conn.prepare(
|
|
335
|
-
"select watcher_id, owner_team_id, task_id, agent_id, leader_id, status, created_at,
|
|
481
|
+
"select watcher_id, owner_team_id, task_id, agent_id, leader_id, recipient, status, created_at,
|
|
336
482
|
result_id, notified_message_id
|
|
337
483
|
from result_watchers
|
|
338
484
|
where status in ('pending', 'notify_failed')
|
|
@@ -346,10 +492,11 @@ pub fn retry_result_deliveries(
|
|
|
346
492
|
"task_id": row.get::<_, Option<String>>(2)?,
|
|
347
493
|
"agent_id": row.get::<_, Option<String>>(3)?,
|
|
348
494
|
"leader_id": row.get::<_, Option<String>>(4)?,
|
|
349
|
-
"
|
|
350
|
-
"
|
|
351
|
-
"
|
|
352
|
-
"
|
|
495
|
+
"recipient": row.get::<_, Option<String>>(5)?,
|
|
496
|
+
"status": row.get::<_, Option<String>>(6)?,
|
|
497
|
+
"created_at": row.get::<_, Option<String>>(7)?,
|
|
498
|
+
"result_id": row.get::<_, Option<String>>(8)?,
|
|
499
|
+
"notified_message_id": row.get::<_, Option<String>>(9)?,
|
|
353
500
|
}))
|
|
354
501
|
})?
|
|
355
502
|
.collect::<Result<Vec<_>, _>>()?;
|
|
@@ -1723,8 +1723,8 @@ pub fn worker_provider_exit_marker(provider_label: &str) -> String {
|
|
|
1723
1723
|
/// as a CHILD of a long-lived shell so provider exit does NOT collapse the
|
|
1724
1724
|
/// worker pane (which under upstream tmux 3.6a private-server bugs can
|
|
1725
1725
|
/// cascade into whole-server death). When the provider exits, the worker
|
|
1726
|
-
/// pane
|
|
1727
|
-
///
|
|
1726
|
+
/// pane remains alive with an explicit worker exit marker, then runs an
|
|
1727
|
+
/// inert `sh` tail that does not read terminal input.
|
|
1728
1728
|
pub fn worker_shell_wrapper_command(
|
|
1729
1729
|
argv: &[String],
|
|
1730
1730
|
cwd: &Path,
|
|
@@ -1758,23 +1758,21 @@ pub fn worker_shell_wrapper_command(
|
|
|
1758
1758
|
worker_provider_exit_marker(provider_label)
|
|
1759
1759
|
)));
|
|
1760
1760
|
parts.push("\"$rc\";".to_string());
|
|
1761
|
-
parts.push(
|
|
1762
|
-
parts.push("\"${SHELL:-/bin/zsh}\"".to_string());
|
|
1763
|
-
parts.push("-l".to_string());
|
|
1761
|
+
parts.push(inert_pane_tail_command());
|
|
1764
1762
|
parts.join(" ")
|
|
1765
1763
|
}
|
|
1766
1764
|
|
|
1767
1765
|
/// 0.4.x (CR C-2): leader shell wrapper — provider runs as a CHILD of a
|
|
1768
1766
|
/// long-lived shell, not as the pane's primary process. When the provider
|
|
1769
|
-
/// exits, the pane
|
|
1770
|
-
///
|
|
1767
|
+
/// exits, the pane remains alive with an explicit exit marker, then runs an
|
|
1768
|
+
/// inert `sh` tail that does not read terminal input.
|
|
1771
1769
|
///
|
|
1772
1770
|
/// Four required envelope sections (CR C-2):
|
|
1773
1771
|
/// 1. cd <cwd> — same as `shell_command`
|
|
1774
1772
|
/// 2. unset <KEY> ... — provider env_unset block
|
|
1775
1773
|
/// 3. KEY=val ... <provider> — env exports + provider invocation
|
|
1776
1774
|
/// (NO `exec` — runs as child)
|
|
1777
|
-
/// 4. printf exit marker; exec
|
|
1775
|
+
/// 4. printf exit marker; exec inert sh tail
|
|
1778
1776
|
///
|
|
1779
1777
|
/// `provider_label` is a human-readable provider name (e.g. "claude",
|
|
1780
1778
|
/// "codex") embedded in the exit marker for diagnostics.
|
|
@@ -1809,7 +1807,7 @@ pub fn leader_shell_wrapper_command(
|
|
|
1809
1807
|
}
|
|
1810
1808
|
parts.extend(argv.iter().map(|arg| shell_quote(arg)));
|
|
1811
1809
|
parts.push(";".to_string());
|
|
1812
|
-
// 4. exit marker +
|
|
1810
|
+
// 4. exit marker + inert shell tail
|
|
1813
1811
|
parts.push("rc=$?;".to_string());
|
|
1814
1812
|
parts.push("printf".to_string());
|
|
1815
1813
|
// CR R6: marker text comes from single-source `leader_provider_exit_marker`.
|
|
@@ -1818,12 +1816,18 @@ pub fn leader_shell_wrapper_command(
|
|
|
1818
1816
|
leader_provider_exit_marker(provider_label)
|
|
1819
1817
|
)));
|
|
1820
1818
|
parts.push("\"$rc\";".to_string());
|
|
1821
|
-
parts.push(
|
|
1822
|
-
parts.push("\"${SHELL:-/bin/zsh}\"".to_string());
|
|
1823
|
-
parts.push("-l".to_string());
|
|
1819
|
+
parts.push(inert_pane_tail_command());
|
|
1824
1820
|
parts.join(" ")
|
|
1825
1821
|
}
|
|
1826
1822
|
|
|
1823
|
+
fn inert_pane_tail_command() -> String {
|
|
1824
|
+
// `sh` is deliberate: provider-exit health checks use the shell basename
|
|
1825
|
+
// to reach the exit-marker branch. `sh -c` does not read commands from
|
|
1826
|
+
// stdin; disabling echo also prevents input from appearing accepted.
|
|
1827
|
+
let script = r#"trap '' INT QUIT; stty -echo 2>/dev/null; printf "%s\n" "[team-agent] Provider exited; this pane no longer accepts input. Restart from another pane with the appropriate team-agent start command."; while :; do sleep 3600 & wait "$!"; done"#;
|
|
1828
|
+
format!("exec /bin/sh -c {}", shell_quote(script))
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1827
1831
|
fn shell_quote(raw: &str) -> String {
|
|
1828
1832
|
if raw.is_empty() {
|
|
1829
1833
|
return "''".to_string();
|
|
@@ -613,11 +613,11 @@ pub trait Transport: Send + Sync {
|
|
|
613
613
|
/// variant that reuses the leader-wrapper mechanism so worker provider
|
|
614
614
|
/// exit does not collapse the pane into `[exited]` (which under
|
|
615
615
|
/// upstream tmux 3.6a private-server bugs can cascade into server
|
|
616
|
-
/// death). When the provider exits, the worker pane
|
|
617
|
-
///
|
|
618
|
-
///
|
|
619
|
-
///
|
|
620
|
-
///
|
|
616
|
+
/// death). When the provider exits, the worker pane keeps an explicit
|
|
617
|
+
/// worker exit marker and runs the inert `/bin/sh -c` tail: it ignores
|
|
618
|
+
/// INT/QUIT and does not read pane stdin. Default falls back to plain
|
|
619
|
+
/// `spawn_first_with_env_unset` for backends that have no shell layer
|
|
620
|
+
/// (test-only `OfflineTransport`).
|
|
621
621
|
fn spawn_first_with_worker_shell_wrapper(
|
|
622
622
|
&self,
|
|
623
623
|
session: &SessionName,
|
|
@@ -650,12 +650,12 @@ pub trait Transport: Send + Sync {
|
|
|
650
650
|
/// 0.4.x (CR C-2): leader-specific spawn variant. Instead of `exec <cmd>`
|
|
651
651
|
/// (which makes the provider the pane's primary process and turns the
|
|
652
652
|
/// pane into `[exited]` when the provider exits), build a shell line
|
|
653
|
-
/// that runs the provider as a CHILD of a long-lived shell
|
|
654
|
-
///
|
|
655
|
-
///
|
|
656
|
-
///
|
|
657
|
-
/// `
|
|
658
|
-
///
|
|
653
|
+
/// that runs the provider as a CHILD of a long-lived shell. When the
|
|
654
|
+
/// provider exits, the wrapper records the exit code and marker, then
|
|
655
|
+
/// execs an inert `/bin/sh -c` tail that ignores INT/QUIT and does not
|
|
656
|
+
/// read pane stdin. Default falls back to plain
|
|
657
|
+
/// `spawn_first_with_env_unset` for backends that have no shell layer
|
|
658
|
+
/// (test-only `OfflineTransport`).
|
|
659
659
|
fn spawn_first_with_leader_shell_wrapper(
|
|
660
660
|
&self,
|
|
661
661
|
session: &SessionName,
|