Skip to main content

monoloop_loop/transaction/
tool_handler.rs

1//! Linked tool handlers, execution handles, and cancellation controls.
2
3use monoloop_contracts::{
4    ToolCall, ToolCallContext, ToolCompletion, ToolExecutionId, ToolStartError,
5};
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
9use std::sync::{Arc, Mutex};
10use tokio::sync::{oneshot, Notify};
11
12/// RAII decrement for [`ShutdownSnapshot::owned_processes`] (§18.2 honesty).
13#[derive(Debug)]
14pub(crate) struct OwnedProcessLease {
15    counter: Arc<AtomicU32>,
16}
17
18impl OwnedProcessLease {
19    fn acquire(counter: Arc<AtomicU32>) -> Self {
20        counter.fetch_add(1, Ordering::SeqCst);
21        Self { counter }
22    }
23}
24
25impl Drop for OwnedProcessLease {
26    fn drop(&mut self) {
27        self.counter.fetch_sub(1, Ordering::SeqCst);
28    }
29}
30
31/// Host-linked tool implementation.
32pub trait ToolHandler: Send + Sync {
33    /// Start one execution. Must return a handle with a single completion.
34    fn start(
35        &self,
36        call: ToolCall,
37        context: ToolCallContext,
38    ) -> Result<LinkedToolExecutionHandle, ToolStartError>;
39
40    /// Whether cooperative/abort cancellation is honored (D-024 / D-028).
41    /// Default **false** (fail-closed): capability booleans must not self-assert.
42    fn supports_abort(&self) -> bool {
43        false
44    }
45
46    /// Whether isolated kill after grace is available (D-024 / D-028).
47    /// Default **false** (fail-closed). For process-isolated tool classes,
48    /// registration also requires [`Self::os_process_isolated`].
49    fn supports_isolated_kill(&self) -> bool {
50        false
51    }
52
53    /// Structural OS process isolation boundary (V2 §14.3).
54    ///
55    /// Default **false**. Only handlers that own a real child process (not a
56    /// Tokio task) may return true. Capability booleans alone are insufficient.
57    fn os_process_isolated(&self) -> bool {
58        false
59    }
60
61    /// Structural AbortableAtYield ownership (V2 §14.2 / D-050).
62    ///
63    /// Default **false**. Only handlers registered via
64    /// [`super::host_tools::RegisteredTool::try_new_abortable`] may return true;
65    /// they yield CancelOnly + an unspawned `drive` future the runtime polls.
66    /// Capability booleans alone are insufficient.
67    fn runtime_owns_abortable_drive(&self) -> bool {
68        false
69    }
70}
71
72mod abortable_seal {
73    /// Seals [`super::AbortableAtYieldHandler`] to crate-defined factories.
74    pub trait Sealed {}
75}
76
77/// Structural AbortableAtYield factory marker (V2 §14.2 / D-050).
78///
79/// Only crate-owned handlers that produce CancelOnly + inline `drive` implement
80/// this. Custom `dyn ToolHandler` values cannot self-assert via `supports_abort`.
81pub trait AbortableAtYieldHandler: ToolHandler + abortable_seal::Sealed {}
82
83/// Cancellation control for a running linked tool.
84#[derive(Clone, Debug)]
85pub struct ToolExecutionControl {
86    cancelled: Arc<AtomicBool>,
87    notify: Arc<Notify>,
88}
89
90impl ToolExecutionControl {
91    /// Create a fresh control channel.
92    pub fn new() -> Self {
93        Self {
94            cancelled: Arc::new(AtomicBool::new(false)),
95            notify: Arc::new(Notify::new()),
96        }
97    }
98
99    /// Request cooperative/abort cancel (idempotent).
100    pub fn cancel(&self) {
101        self.cancelled.store(true, Ordering::SeqCst);
102        self.notify.notify_waiters();
103    }
104
105    /// Whether cancel was requested.
106    pub fn is_cancelled(&self) -> bool {
107        self.cancelled.load(Ordering::SeqCst)
108    }
109
110    /// Wait until cancelled.
111    pub async fn cancelled(&self) {
112        loop {
113            if self.is_cancelled() {
114                return;
115            }
116            self.notify.notified().await;
117        }
118    }
119}
120
121impl Default for ToolExecutionControl {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127/// One-shot completion consumer for a linked tool execution.
128#[derive(Debug)]
129pub struct ToolExecutionCompletion {
130    rx: oneshot::Receiver<ToolCompletion>,
131}
132
133impl ToolExecutionCompletion {
134    /// Wrap a receiver (exactly-once consumption via [`Self::wait`]).
135    pub fn new(rx: oneshot::Receiver<ToolCompletion>) -> Self {
136        Self { rx }
137    }
138
139    /// Await the single completion (or lost-completion if dropped).
140    pub async fn wait(self) -> ToolCompletion {
141        self.rx.await.unwrap_or(ToolCompletion::RuntimeFailed(
142            monoloop_contracts::ToolRuntimeError::CompletionLost,
143        ))
144    }
145}
146
147/// Force-stop + join for Abortable (Tokio) or ProcessIsolated (OS child) workers.
148///
149/// Timed waits must not drop the join on timeout (put-back) or the worker would
150/// detach while capacity is released. Process kill uses OS signals (D-043).
151#[derive(Clone, Debug)]
152pub struct ToolKillHandle {
153    inner: Arc<KillInner>,
154}
155
156#[derive(Debug)]
157enum KillInner {
158    /// Inline AbortableAtYield body driven on the caller's task (M5.4 — no ambient spawn).
159    ///
160    /// `kill` cancels [`ToolExecutionControl`]; dropping the drive future stops
161    /// work at the next `.await`. No separate JoinHandle to park.
162    CancelOnly { control: ToolExecutionControl },
163    /// OS child process — real kill boundary (V2 §14.3).
164    Process {
165        child: Arc<Mutex<Option<tokio::process::Child>>>,
166        /// Live until the child is observed reaped (or spill/Drop releases).
167        owned_slot: Mutex<Option<OwnedProcessLease>>,
168    },
169}
170
171impl ToolKillHandle {
172    /// AbortableAtYield without a nested Tokio task (M5.4).
173    ///
174    /// Caller drives [`LinkedToolExecutionHandle::drive`] on the supervised
175    /// dispatch task; `kill` requests cooperative cancel via `control`.
176    pub fn cancel_only(control: ToolExecutionControl) -> Self {
177        Self {
178            inner: Arc::new(KillInner::CancelOnly { control }),
179        }
180    }
181
182    /// Own an OS [`tokio::process::Child`] (D-043 / M5.4 / D-048).
183    ///
184    /// Wait/poll runs on [`LinkedToolExecutionHandle::drive`] (no ambient
185    /// `spawn_blocking`). `child` is shared so kill and the drive loop observe
186    /// the same process.
187    pub(crate) fn from_process(child: Arc<Mutex<Option<tokio::process::Child>>>) -> Self {
188        Self {
189            inner: Arc::new(KillInner::Process {
190                child,
191                owned_slot: Mutex::new(None),
192            }),
193        }
194    }
195
196    /// Register this ProcessIsolated child in the runtime `owned_processes` count.
197    ///
198    /// Idempotent. Call from the dispatcher after `start` when a shared counter exists.
199    pub fn register_owned_process(&self, counter: Arc<AtomicU32>) {
200        let KillInner::Process { owned_slot, .. } = &*self.inner else {
201            return;
202        };
203        let mut slot = owned_slot.lock().unwrap_or_else(|e| e.into_inner());
204        if slot.is_none() {
205            *slot = Some(OwnedProcessLease::acquire(counter));
206        }
207    }
208
209    /// Release the owned-process lease once the child is observed reaped.
210    pub fn note_process_reaped(&self) {
211        let KillInner::Process { owned_slot, .. } = &*self.inner else {
212            return;
213        };
214        let _ = owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take();
215    }
216
217    /// Take the lease for spill parking (keeps `owned_processes` honest across Drop).
218    #[allow(dead_code)] // retained for D-048 registry / spill compatibility
219    pub(crate) fn take_process_lease(&self) -> Option<OwnedProcessLease> {
220        let KillInner::Process { owned_slot, .. } = &*self.inner else {
221            return None;
222        };
223        owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take()
224    }
225
226    /// Request cancel (CancelOnly) or OS-kill the child (ProcessIsolated). Idempotent.
227    pub fn kill(&self) {
228        match &*self.inner {
229            KillInner::CancelOnly { control } => control.cancel(),
230            KillInner::Process { child, .. } => {
231                if let Some(c) = child.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
232                    let _ = c.start_kill();
233                }
234            }
235        }
236    }
237
238    /// Await worker teardown. On timeout, ProcessIsolated leaves the child owned
239    /// so capacity stays held; caller must keep joining or park an orphan permit.
240    pub async fn join_timeout(&self, budget: std::time::Duration) -> Result<(), ()> {
241        match &*self.inner {
242            KillInner::CancelOnly { .. } => {
243                // Inline drive: caller drops/polls the drive future; no join to await.
244                Ok(())
245            }
246            KillInner::Process { child, owned_slot } => {
247                // Drive-owned wait: poll try_wait until exit or budget (no mutex across await).
248                let deadline = std::time::Instant::now() + budget;
249                loop {
250                    let done = {
251                        let mut guard = child.lock().unwrap_or_else(|e| e.into_inner());
252                        match guard.as_mut() {
253                            Some(c) => match c.try_wait() {
254                                Ok(Some(_)) => {
255                                    let _ = guard.take();
256                                    true
257                                }
258                                Ok(None) => false,
259                                Err(_) => true,
260                            },
261                            None => true,
262                        }
263                    };
264                    if done {
265                        let _ = owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take();
266                        return Ok(());
267                    }
268                    if std::time::Instant::now() >= deadline {
269                        return Err(());
270                    }
271                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
272                }
273            }
274        }
275    }
276
277    /// Whether unfinished ProcessIsolated work is still owned (capacity hold).
278    pub fn has_join(&self) -> bool {
279        match &*self.inner {
280            KillInner::Process { child, owned_slot } => {
281                // Drive-owned wait: capacity stays held until the child is observed exited.
282                if Self::process_still_alive(child) {
283                    true
284                } else {
285                    let _ = owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take();
286                    false
287                }
288            }
289            KillInner::CancelOnly { .. } => false,
290        }
291    }
292
293    fn process_still_alive(child: &Mutex<Option<tokio::process::Child>>) -> bool {
294        let mut guard = child.lock().unwrap_or_else(|e| e.into_inner());
295        match guard.as_mut() {
296            Some(c) => match c.try_wait() {
297                Ok(None) => true,
298                Ok(Some(_)) => {
299                    // Reaped — drop the Child so Drop does not wait again.
300                    let _ = guard.take();
301                    false
302                }
303                Err(_) => true, // fail-closed: treat as still owned
304            },
305            None => false,
306        }
307    }
308
309    /// True when this handle owns an OS process (ProcessIsolated).
310    pub fn is_process_isolated(&self) -> bool {
311        matches!(&*self.inner, KillInner::Process { .. })
312    }
313
314    /// OS PID of a live ProcessIsolated child, if still owned.
315    ///
316    /// Used by sacrificial proofs (D-048) to assert kill/reap without ambient heuristics.
317    pub fn os_pid(&self) -> Option<u32> {
318        match &*self.inner {
319            KillInner::Process { child, .. } => {
320                let guard = child.lock().unwrap_or_else(|e| e.into_inner());
321                guard.as_ref().and_then(|c| c.id())
322            }
323            KillInner::CancelOnly { .. } => None,
324        }
325    }
326
327    /// True when the body is driven inline on the caller task (no nested JoinHandle).
328    pub fn is_cancel_only(&self) -> bool {
329        matches!(&*self.inner, KillInner::CancelOnly { .. })
330    }
331}
332
333/// Handle returned from [`ToolHandler::start`].
334pub struct LinkedToolExecutionHandle {
335    /// Stable execution id for this start.
336    pub execution_id: ToolExecutionId,
337    /// Cancellation control.
338    pub control: ToolExecutionControl,
339    /// Exactly-once completion.
340    pub completion: ToolExecutionCompletion,
341    /// Optional kill handle for escalate-after-grace (D-024).
342    pub kill: Option<ToolKillHandle>,
343    /// When `Some`, the dispatcher MUST poll this on the current task (M5.4).
344    /// Completes by sending on [`Self::completion`]. No ambient `tokio::spawn`.
345    pub drive: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
346}
347
348impl std::fmt::Debug for LinkedToolExecutionHandle {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        f.debug_struct("LinkedToolExecutionHandle")
351            .field("execution_id", &self.execution_id)
352            .field("control", &self.control)
353            .field("completion", &self.completion)
354            .field("kill", &self.kill)
355            .field("drive", &self.drive.as_ref().map(|_| "<drive>"))
356            .finish()
357    }
358}
359
360/// Handler that completes immediately from a synchronous function.
361pub struct ImmediateToolHandler<F> {
362    f: F,
363}
364
365impl<F> ImmediateToolHandler<F>
366where
367    F: Fn(ToolCall, ToolCallContext) -> Result<ToolCompletion, ToolStartError> + Send + Sync,
368{
369    /// Construct from a function.
370    pub fn new(f: F) -> Self {
371        Self { f }
372    }
373}
374
375impl<F> ToolHandler for ImmediateToolHandler<F>
376where
377    F: Fn(ToolCall, ToolCallContext) -> Result<ToolCompletion, ToolStartError> + Send + Sync,
378{
379    fn start(
380        &self,
381        call: ToolCall,
382        context: ToolCallContext,
383    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
384        let completion = (self.f)(call, context)?;
385        let (tx, rx) = oneshot::channel();
386        let _ = tx.send(completion);
387        Ok(LinkedToolExecutionHandle {
388            execution_id: ToolExecutionId::generate(),
389            control: ToolExecutionControl::new(),
390            completion: ToolExecutionCompletion::new(rx),
391            kill: None,
392            drive: None,
393        })
394    }
395}
396
397type BoxFut = Pin<Box<dyn Future<Output = ToolCompletion> + Send>>;
398
399/// Handler that runs an async body with abortable cancellation.
400///
401/// M5.4: body is returned as [`LinkedToolExecutionHandle::drive`] and polled on
402/// the caller's task (the supervised ToolWorker dispatch path). No ambient
403/// `tokio::spawn`. Cancel via [`ToolKillHandle::cancel_only`].
404pub struct AsyncToolHandler<F> {
405    f: F,
406}
407
408impl<F> AsyncToolHandler<F>
409where
410    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync,
411{
412    /// Construct from a function that returns a boxed future.
413    pub fn new(f: F) -> Self {
414        Self { f }
415    }
416}
417
418impl<F> ToolHandler for AsyncToolHandler<F>
419where
420    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync,
421{
422    fn start(
423        &self,
424        call: ToolCall,
425        context: ToolCallContext,
426    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
427        let control = ToolExecutionControl::new();
428        let control_body = control.clone();
429        let fut = (self.f)(call, context, control_body.clone());
430        let (tx, rx) = oneshot::channel();
431        // Drive inline on the dispatcher/ToolWorker task — Law 23 / M5.4.
432        let drive = Box::pin(async move {
433            tokio::select! {
434                biased;
435                _ = control_body.cancelled() => {
436                    let _ = tx.send(ToolCompletion::RuntimeFailed(
437                        monoloop_contracts::ToolRuntimeError::TerminationFailed,
438                    ));
439                }
440                result = fut => {
441                    let _ = tx.send(result);
442                }
443            }
444        });
445        let kill = ToolKillHandle::cancel_only(control.clone());
446        Ok(LinkedToolExecutionHandle {
447            execution_id: ToolExecutionId::generate(),
448            control,
449            completion: ToolExecutionCompletion::new(rx),
450            kill: Some(kill),
451            drive: Some(drive),
452        })
453    }
454
455    fn supports_abort(&self) -> bool {
456        true
457    }
458
459    fn runtime_owns_abortable_drive(&self) -> bool {
460        true
461    }
462}
463
464impl<F> abortable_seal::Sealed for AsyncToolHandler<F> where
465    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync
466{
467}
468
469impl<F> AbortableAtYieldHandler for AsyncToolHandler<F> where
470    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync
471{
472}
473
474/// Stubborn in-process worker for AbortableAtYield / legacy D-024 fixtures.
475///
476/// **Not** ProcessIsolated: termination is cancel + dropping the inline drive
477/// (abort-at-yield of the caller task). [`Self::os_process_isolated`] is false.
478/// Prefer [`super::process_tool::ProcessIsolatedToolHandler`] for real OS kill.
479pub struct IsolatedKillableToolHandler<F> {
480    f: F,
481}
482
483impl<F> IsolatedKillableToolHandler<F>
484where
485    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync,
486{
487    /// Construct from a function that returns a boxed future.
488    pub fn new(f: F) -> Self {
489        Self { f }
490    }
491}
492
493impl<F> ToolHandler for IsolatedKillableToolHandler<F>
494where
495    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync,
496{
497    fn start(
498        &self,
499        call: ToolCall,
500        context: ToolCallContext,
501    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
502        let control = ToolExecutionControl::new();
503        let control_body = control.clone();
504        let fut = (self.f)(call, context);
505        let (tx, rx) = oneshot::channel();
506        // Inline drive: ignores cooperative cancel unless the body polls it;
507        // deadline path drops this future (abort-at-yield of the caller task).
508        let drive = Box::pin(async move {
509            let _ = control_body;
510            let result = fut.await;
511            let _ = tx.send(result);
512        });
513        let kill = ToolKillHandle::cancel_only(control.clone());
514        Ok(LinkedToolExecutionHandle {
515            execution_id: ToolExecutionId::generate(),
516            control,
517            completion: ToolExecutionCompletion::new(rx),
518            kill: Some(kill),
519            drive: Some(drive),
520        })
521    }
522
523    fn supports_abort(&self) -> bool {
524        // Abort-at-yield of the caller/dispatch task — not OS isolation.
525        true
526    }
527
528    fn supports_isolated_kill(&self) -> bool {
529        // D-043: Tokio abort must not satisfy ProcessIsolated registration.
530        false
531    }
532
533    fn runtime_owns_abortable_drive(&self) -> bool {
534        true
535    }
536}
537
538impl<F> abortable_seal::Sealed for IsolatedKillableToolHandler<F> where
539    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync
540{
541}
542
543impl<F> AbortableAtYieldHandler for IsolatedKillableToolHandler<F> where
544    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync
545{
546}
547
548/// Handler that always fails at start (tests).
549#[derive(Debug, Default)]
550pub struct StartFailHandler {
551    /// Rejection message.
552    pub reason: &'static str,
553}
554
555impl ToolHandler for StartFailHandler {
556    fn start(
557        &self,
558        _call: ToolCall,
559        _context: ToolCallContext,
560    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
561        Err(ToolStartError::Rejected(self.reason))
562    }
563}
564
565/// Handler that panics on start (tests).
566#[derive(Debug, Default)]
567pub struct PanicOnStartHandler;
568
569impl ToolHandler for PanicOnStartHandler {
570    fn start(
571        &self,
572        _call: ToolCall,
573        _context: ToolCallContext,
574    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
575        panic!("deliberate tool panic");
576    }
577}
578
579/// Handler whose completion is never sent (tests).
580#[derive(Debug, Default)]
581pub struct LostCompletionHandler;
582
583impl ToolHandler for LostCompletionHandler {
584    fn start(
585        &self,
586        _call: ToolCall,
587        _context: ToolCallContext,
588    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
589        let (tx, rx) = oneshot::channel();
590        drop(tx);
591        Ok(LinkedToolExecutionHandle {
592            execution_id: ToolExecutionId::generate(),
593            control: ToolExecutionControl::new(),
594            completion: ToolExecutionCompletion::new(rx),
595            kill: None,
596            drive: None,
597        })
598    }
599}