@team-agent/installer 0.5.64 → 0.5.66

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.
Files changed (105) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +5 -0
  4. package/crates/team-agent/src/cli/adapters.rs +6 -1
  5. package/crates/team-agent/src/cli/emit.rs +15 -0
  6. package/crates/team-agent/src/cli/mod.rs +9 -8
  7. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  8. package/crates/team-agent/src/cli/spec.rs +3 -0
  9. package/crates/team-agent/src/cli/tests/compile.rs +1 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +33 -2
  11. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -1
  12. package/crates/team-agent/src/cli/tests/status_send.rs +13 -3
  13. package/crates/team-agent/src/cli/tests/verb_validate.rs +1 -1
  14. package/crates/team-agent/src/cli/types.rs +7 -0
  15. package/crates/team-agent/src/compiler/tests.rs +23 -20
  16. package/crates/team-agent/src/compiler.rs +25 -5
  17. package/crates/team-agent/src/conpty/backend.rs +16 -0
  18. package/crates/team-agent/src/coordinator/health.rs +17 -0
  19. package/crates/team-agent/src/coordinator/steps/abnormal.rs +55 -2
  20. package/crates/team-agent/src/db/migration.rs +2 -1
  21. package/crates/team-agent/src/db/schema.rs +16 -8
  22. package/crates/team-agent/src/leader/lease.rs +1 -2
  23. package/crates/team-agent/src/leader/start.rs +175 -10
  24. package/crates/team-agent/src/leader/tests/team_in_team_state_scope_red.rs +1 -1
  25. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +0 -2
  26. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +7 -2
  27. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +137 -2
  28. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +1 -2
  29. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +5 -0
  30. package/crates/team-agent/src/lifecycle/launch/spawn.rs +39 -2
  31. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +105 -48
  32. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +0 -2
  33. package/crates/team-agent/src/lifecycle/launch.rs +117 -21
  34. package/crates/team-agent/src/lifecycle/restart/agent.rs +89 -3
  35. package/crates/team-agent/src/lifecycle/restart/common.rs +9 -12
  36. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +59 -15
  37. package/crates/team-agent/src/lifecycle/tests/acceptance_b_batch_red.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +4 -4
  39. package/crates/team-agent/src/lifecycle/tests/behavioral_diff_264_red.rs +1 -1
  40. package/crates/team-agent/src/lifecycle/tests/claude_compatible_config_red.rs +2 -2
  41. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +4 -4
  42. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +24 -32
  43. package/crates/team-agent/src/lifecycle/tests/communication_mode_runtime_contract_red.rs +2 -2
  44. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +13 -11
  45. package/crates/team-agent/src/lifecycle/tests/core.rs +0 -18
  46. package/crates/team-agent/src/lifecycle/tests/core_034_real_red.rs +1 -1
  47. package/crates/team-agent/src/lifecycle/tests/display_adaptive_red.rs +1 -1
  48. package/crates/team-agent/src/lifecycle/tests/f032_startup_prompt_best_effort_red.rs +1 -1
  49. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +3 -0
  50. package/crates/team-agent/src/lifecycle/tests/host_cotenant_death_p0_contract.rs +3 -3
  51. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +2 -2
  52. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +17 -30
  53. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +1 -1
  54. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +4 -4
  55. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  56. package/crates/team-agent/src/lifecycle/tests/quick_start_worker_readiness_red.rs +2 -2
  57. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +1 -1
  58. package/crates/team-agent/src/lifecycle/tests/restart_liveness_red.rs +1 -1
  59. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/restart_session_capture_red.rs +2 -2
  61. package/crates/team-agent/src/lifecycle/tests/resume_recover_red.rs +1 -1
  62. package/crates/team-agent/src/lifecycle/tests/stale_team_saveconflict_contract.rs +1 -1
  63. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +11 -7
  64. package/crates/team-agent/src/lifecycle/tests/status_credential_redaction_contract.rs +3 -3
  65. package/crates/team-agent/src/lifecycle/tests/subprep_codex_trust_roundtrip_red.rs +1 -1
  66. package/crates/team-agent/src/lifecycle/tests/swallow_batch4_semantics_red.rs +1 -1
  67. package/crates/team-agent/src/lifecycle/tests/team_in_team_identity_scope_red.rs +1 -1
  68. package/crates/team-agent/src/lifecycle/tests/team_in_team_sibling_quick_start_red.rs +1 -1
  69. package/crates/team-agent/src/lifecycle/tests/team_in_team_state_scope_red.rs +1 -1
  70. package/crates/team-agent/src/lifecycle/tests/team_key_retirement_txn_red.rs +1 -1
  71. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +1 -1
  72. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +1 -1
  73. package/crates/team-agent/src/lifecycle/tests/verify_rs031_window_consistency_red.rs +2 -2
  74. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +1 -1
  75. package/crates/team-agent/src/lifecycle/tests.rs +11 -7
  76. package/crates/team-agent/src/lifecycle/types.rs +0 -2
  77. package/crates/team-agent/src/lifecycle/worker_command_context.rs +115 -22
  78. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  79. package/crates/team-agent/src/messaging/delivery.rs +29 -0
  80. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  81. package/crates/team-agent/src/messaging/leader_receiver.rs +12 -0
  82. package/crates/team-agent/src/messaging/mod.rs +2 -0
  83. package/crates/team-agent/src/messaging/results.rs +5 -0
  84. package/crates/team-agent/src/messaging/send.rs +9 -0
  85. package/crates/team-agent/src/messaging/tests/main_preserved.rs +1 -1
  86. package/crates/team-agent/src/messaging/tests/runtime.rs +32 -0
  87. package/crates/team-agent/src/messaging/types.rs +2 -0
  88. package/crates/team-agent/src/messaging/wait.rs +366 -0
  89. package/crates/team-agent/src/messaging/watchers.rs +153 -6
  90. package/crates/team-agent/src/model/spec.rs +129 -6
  91. package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +3 -1
  92. package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +3 -1
  93. package/crates/team-agent/src/model/testdata/team.spec.yaml +3 -1
  94. package/crates/team-agent/src/provider/adapters/claude.rs +5 -2
  95. package/crates/team-agent/src/provider/adapters/codex.rs +5 -2
  96. package/crates/team-agent/src/provider/adapters/copilot.rs +5 -2
  97. package/crates/team-agent/src/provider/bypass_flags.rs +80 -0
  98. package/crates/team-agent/src/provider/mod.rs +2 -0
  99. package/crates/team-agent/src/tmux_backend.rs +35 -12
  100. package/crates/team-agent/src/transport/test_support.rs +20 -0
  101. package/crates/team-agent/src/transport.rs +28 -11
  102. package/package.json +4 -4
  103. package/crates/team-agent/src/lifecycle/launch/approval.rs +0 -134
  104. package/crates/team-agent/src/lifecycle/tests/codex_mcp_approval_inheritance_red.rs +0 -1187
  105. package/crates/team-agent/src/lifecycle/tests/worker_dangerous_bypass_argv_red.rs +0 -327
@@ -214,6 +214,7 @@ pub fn send_message(
214
214
  stage: None,
215
215
  reason: Some(crate::messaging::DeliveryRefusal::EmptyTargetList),
216
216
  channel: None,
217
+ ack_forced_off: false,
217
218
  });
218
219
  }
219
220
  MessageTarget::Fanout(recipients) => {
@@ -351,6 +352,7 @@ pub fn send_message(
351
352
  stage: None,
352
353
  reason: None,
353
354
  channel: None,
355
+ ack_forced_off: false,
354
356
  })
355
357
  }
356
358
 
@@ -404,6 +406,7 @@ fn persist_stored_only_send(
404
406
  stage: None,
405
407
  reason: None,
406
408
  channel: Some(presentation.effective_sink.as_str().to_string()),
409
+ ack_forced_off: false,
407
410
  })
408
411
  }
409
412
 
@@ -451,6 +454,7 @@ fn refused_outcome_with_id(reason: DeliveryRefusal, message_id: Option<String>)
451
454
  stage: None,
452
455
  reason: Some(reason),
453
456
  channel: None,
457
+ ack_forced_off: false,
454
458
  }
455
459
  }
456
460
 
@@ -467,6 +471,7 @@ fn refused_outcome_with_verification(
467
471
  stage: None,
468
472
  reason: Some(reason),
469
473
  channel: None,
474
+ ack_forced_off: false,
470
475
  }
471
476
  }
472
477
 
@@ -549,6 +554,7 @@ fn coordinator_unavailable_outcome(
549
554
  stage: None,
550
555
  reason: Some(DeliveryRefusal::CoordinatorUnavailable),
551
556
  channel: Some("coordinator_unavailable".to_string()),
557
+ ack_forced_off: false,
552
558
  },
553
559
  }))
554
560
  }
@@ -573,6 +579,7 @@ fn rebind_required_outcome_with_verification(
573
579
  stage: None,
574
580
  reason: Some(DeliveryRefusal::LeaderNotAttached),
575
581
  channel: Some("rebind_required".to_string()),
582
+ ack_forced_off: false,
576
583
  }
577
584
  }
578
585
 
@@ -804,6 +811,7 @@ pub(crate) fn session_drift_refusal(
804
811
  stage: None,
805
812
  reason: Some(DeliveryRefusal::SessionDrift),
806
813
  channel: None,
814
+ ack_forced_off: false,
807
815
  }))
808
816
  }
809
817
 
@@ -924,5 +932,6 @@ fn fanout_send(
924
932
  } else {
925
933
  channel_label.to_string()
926
934
  }),
935
+ ack_forced_off: false,
927
936
  })
928
937
  }
@@ -94,7 +94,7 @@ fn collect_result_file_ingests_valid_known_task_envelope_into_results() {
94
94
  .unwrap();
95
95
  std::fs::write(
96
96
  team.join("agents").join("w1.md"),
97
- "---\nname: w1\nrole: Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ntools:\n - mcp_team\n---\n\nW1.\n",
97
+ "---\nname: w1\nrole: Worker\nprovider: codex\nmodel: gpt-5.5\nauth_mode: subscription\ndangerously_skip_permissions: false\ntools:\n - mcp_team\n---\n\nW1.\n",
98
98
  )
99
99
  .unwrap();
100
100
  let spec = crate::compiler::compile_team(&team).expect("compile collect team");
@@ -709,6 +709,38 @@ fn report_result_invalid_envelope_errors_validation() {
709
709
  );
710
710
  }
711
711
 
712
+ #[test]
713
+ fn leader_receiver_reports_requires_ack_forced_off() {
714
+ let ws = tmp_ws("ack-forced-off");
715
+ let log = EventLog::new(&ws);
716
+ let state = json(serde_json::json!({
717
+ "active_team_key": "team-a",
718
+ "teams": {"team-a": {}}
719
+ }));
720
+
721
+ let outcome = send_to_leader_receiver(
722
+ &ws,
723
+ &state,
724
+ "leader",
725
+ "ack contract probe",
726
+ None,
727
+ "worker",
728
+ true,
729
+ None,
730
+ &log,
731
+ )
732
+ .unwrap();
733
+
734
+ let value = crate::mcp_server::helpers::delivery_outcome_value(&outcome);
735
+ assert_eq!(
736
+ value
737
+ .get("ack_forced_off")
738
+ .and_then(serde_json::Value::as_bool),
739
+ Some(true),
740
+ "requires_ack=true must report that the unavailable ack guarantee was forced off"
741
+ );
742
+ }
743
+
712
744
  // ════════════════════════════════════════════════════════════════════════
713
745
  // GROUP N — notify_result_watchers dedupe (exactly-once, Gap 32/38).
714
746
  // result_delivery.py:38-132. superseded for duplicate watchers same result.
@@ -295,6 +295,8 @@ pub enum PaneWidthQuery {
295
295
  #[derive(Debug, Clone, PartialEq, Eq)]
296
296
  pub struct DeliveryOutcome {
297
297
  pub ok: bool,
298
+ /// Whether a caller-requested ack guarantee was unavailable and forced off.
299
+ pub ack_forced_off: bool,
298
300
  /// 投递层结果态 (≠ 行态)。
299
301
  pub status: DeliveryStatus,
300
302
  /// step 7 `messages.status` 行态原文 (shadow;leader 集成时收口为 step 7 enum)。
@@ -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
- "leader",
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
- "status": row.get::<_, Option<String>>(5)?,
350
- "created_at": row.get::<_, Option<String>>(6)?,
351
- "result_id": row.get::<_, Option<String>>(7)?,
352
- "notified_message_id": row.get::<_, Option<String>>(8)?,
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<_>, _>>()?;