monoloop-loop 0.1.3

Minimal extensible Loop: lossless canonical subscription, empty-capable tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
//! Linked tool handlers, execution handles, and cancellation controls.

use monoloop_contracts::{
    ToolCall, ToolCallContext, ToolCompletion, ToolExecutionId, ToolStartError,
};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{oneshot, Notify};

/// RAII decrement for [`ShutdownSnapshot::owned_processes`] (§18.2 honesty).
#[derive(Debug)]
pub(crate) struct OwnedProcessLease {
    counter: Arc<AtomicU32>,
}

impl OwnedProcessLease {
    fn acquire(counter: Arc<AtomicU32>) -> Self {
        counter.fetch_add(1, Ordering::SeqCst);
        Self { counter }
    }
}

impl Drop for OwnedProcessLease {
    fn drop(&mut self) {
        self.counter.fetch_sub(1, Ordering::SeqCst);
    }
}

/// Host-linked tool implementation.
pub trait ToolHandler: Send + Sync {
    /// Start one execution. Must return a handle with a single completion.
    fn start(
        &self,
        call: ToolCall,
        context: ToolCallContext,
    ) -> Result<LinkedToolExecutionHandle, ToolStartError>;

    /// Whether cooperative/abort cancellation is honored (D-024 / D-028).
    /// Default **false** (fail-closed): capability booleans must not self-assert.
    fn supports_abort(&self) -> bool {
        false
    }

    /// Whether isolated kill after grace is available (D-024 / D-028).
    /// Default **false** (fail-closed). For process-isolated tool classes,
    /// registration also requires [`Self::os_process_isolated`].
    fn supports_isolated_kill(&self) -> bool {
        false
    }

    /// Structural OS process isolation boundary (V2 §14.3).
    ///
    /// Default **false**. Only handlers that own a real child process (not a
    /// Tokio task) may return true. Capability booleans alone are insufficient.
    fn os_process_isolated(&self) -> bool {
        false
    }

    /// Structural AbortableAtYield ownership (V2 §14.2 / D-050).
    ///
    /// Default **false**. Only handlers registered via
    /// [`super::host_tools::RegisteredTool::try_new_abortable`] may return true;
    /// they yield CancelOnly + an unspawned `drive` future the runtime polls.
    /// Capability booleans alone are insufficient.
    fn runtime_owns_abortable_drive(&self) -> bool {
        false
    }
}

mod abortable_seal {
    /// Seals [`super::AbortableAtYieldHandler`] to crate-defined factories.
    pub trait Sealed {}
}

/// Structural AbortableAtYield factory marker (V2 §14.2 / D-050).
///
/// Only crate-owned handlers that produce CancelOnly + inline `drive` implement
/// this. Custom `dyn ToolHandler` values cannot self-assert via `supports_abort`.
pub trait AbortableAtYieldHandler: ToolHandler + abortable_seal::Sealed {}

/// Cancellation control for a running linked tool.
#[derive(Clone, Debug)]
pub struct ToolExecutionControl {
    cancelled: Arc<AtomicBool>,
    notify: Arc<Notify>,
}

impl ToolExecutionControl {
    /// Create a fresh control channel.
    pub fn new() -> Self {
        Self {
            cancelled: Arc::new(AtomicBool::new(false)),
            notify: Arc::new(Notify::new()),
        }
    }

    /// Request cooperative/abort cancel (idempotent).
    pub fn cancel(&self) {
        self.cancelled.store(true, Ordering::SeqCst);
        self.notify.notify_waiters();
    }

    /// Whether cancel was requested.
    pub fn is_cancelled(&self) -> bool {
        self.cancelled.load(Ordering::SeqCst)
    }

    /// Wait until cancelled.
    pub async fn cancelled(&self) {
        loop {
            if self.is_cancelled() {
                return;
            }
            self.notify.notified().await;
        }
    }
}

impl Default for ToolExecutionControl {
    fn default() -> Self {
        Self::new()
    }
}

/// One-shot completion consumer for a linked tool execution.
#[derive(Debug)]
pub struct ToolExecutionCompletion {
    rx: oneshot::Receiver<ToolCompletion>,
}

impl ToolExecutionCompletion {
    /// Wrap a receiver (exactly-once consumption via [`Self::wait`]).
    pub fn new(rx: oneshot::Receiver<ToolCompletion>) -> Self {
        Self { rx }
    }

    /// Await the single completion (or lost-completion if dropped).
    pub async fn wait(self) -> ToolCompletion {
        self.rx.await.unwrap_or(ToolCompletion::RuntimeFailed(
            monoloop_contracts::ToolRuntimeError::CompletionLost,
        ))
    }
}

/// Force-stop + join for Abortable (Tokio) or ProcessIsolated (OS child) workers.
///
/// Timed waits must not drop the join on timeout (put-back) or the worker would
/// detach while capacity is released. Process kill uses OS signals (D-043).
#[derive(Clone, Debug)]
pub struct ToolKillHandle {
    inner: Arc<KillInner>,
}

#[derive(Debug)]
enum KillInner {
    /// Inline AbortableAtYield body driven on the caller's task (M5.4 — no ambient spawn).
    ///
    /// `kill` cancels [`ToolExecutionControl`]; dropping the drive future stops
    /// work at the next `.await`. No separate JoinHandle to park.
    CancelOnly { control: ToolExecutionControl },
    /// OS child process — real kill boundary (V2 §14.3).
    Process {
        child: Arc<Mutex<Option<tokio::process::Child>>>,
        /// Live until the child is observed reaped (or spill/Drop releases).
        owned_slot: Mutex<Option<OwnedProcessLease>>,
    },
}

impl ToolKillHandle {
    /// AbortableAtYield without a nested Tokio task (M5.4).
    ///
    /// Caller drives [`LinkedToolExecutionHandle::drive`] on the supervised
    /// dispatch task; `kill` requests cooperative cancel via `control`.
    pub fn cancel_only(control: ToolExecutionControl) -> Self {
        Self {
            inner: Arc::new(KillInner::CancelOnly { control }),
        }
    }

    /// Own an OS [`tokio::process::Child`] (D-043 / M5.4 / D-048).
    ///
    /// Wait/poll runs on [`LinkedToolExecutionHandle::drive`] (no ambient
    /// `spawn_blocking`). `child` is shared so kill and the drive loop observe
    /// the same process.
    pub(crate) fn from_process(child: Arc<Mutex<Option<tokio::process::Child>>>) -> Self {
        Self {
            inner: Arc::new(KillInner::Process {
                child,
                owned_slot: Mutex::new(None),
            }),
        }
    }

    /// Register this ProcessIsolated child in the runtime `owned_processes` count.
    ///
    /// Idempotent. Call from the dispatcher after `start` when a shared counter exists.
    pub fn register_owned_process(&self, counter: Arc<AtomicU32>) {
        let KillInner::Process { owned_slot, .. } = &*self.inner else {
            return;
        };
        let mut slot = owned_slot.lock().unwrap_or_else(|e| e.into_inner());
        if slot.is_none() {
            *slot = Some(OwnedProcessLease::acquire(counter));
        }
    }

    /// Release the owned-process lease once the child is observed reaped.
    pub fn note_process_reaped(&self) {
        let KillInner::Process { owned_slot, .. } = &*self.inner else {
            return;
        };
        let _ = owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take();
    }

    /// Take the lease for spill parking (keeps `owned_processes` honest across Drop).
    #[allow(dead_code)] // retained for D-048 registry / spill compatibility
    pub(crate) fn take_process_lease(&self) -> Option<OwnedProcessLease> {
        let KillInner::Process { owned_slot, .. } = &*self.inner else {
            return None;
        };
        owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take()
    }

    /// Request cancel (CancelOnly) or OS-kill the child (ProcessIsolated). Idempotent.
    pub fn kill(&self) {
        match &*self.inner {
            KillInner::CancelOnly { control } => control.cancel(),
            KillInner::Process { child, .. } => {
                if let Some(c) = child.lock().unwrap_or_else(|e| e.into_inner()).as_mut() {
                    let _ = c.start_kill();
                }
            }
        }
    }

    /// Await worker teardown. On timeout, ProcessIsolated leaves the child owned
    /// so capacity stays held; caller must keep joining or park an orphan permit.
    pub async fn join_timeout(&self, budget: std::time::Duration) -> Result<(), ()> {
        match &*self.inner {
            KillInner::CancelOnly { .. } => {
                // Inline drive: caller drops/polls the drive future; no join to await.
                Ok(())
            }
            KillInner::Process { child, owned_slot } => {
                // Drive-owned wait: poll try_wait until exit or budget (no mutex across await).
                let deadline = std::time::Instant::now() + budget;
                loop {
                    let done = {
                        let mut guard = child.lock().unwrap_or_else(|e| e.into_inner());
                        match guard.as_mut() {
                            Some(c) => match c.try_wait() {
                                Ok(Some(_)) => {
                                    let _ = guard.take();
                                    true
                                }
                                Ok(None) => false,
                                Err(_) => true,
                            },
                            None => true,
                        }
                    };
                    if done {
                        let _ = owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take();
                        return Ok(());
                    }
                    if std::time::Instant::now() >= deadline {
                        return Err(());
                    }
                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
                }
            }
        }
    }

    /// Whether unfinished ProcessIsolated work is still owned (capacity hold).
    pub fn has_join(&self) -> bool {
        match &*self.inner {
            KillInner::Process { child, owned_slot } => {
                // Drive-owned wait: capacity stays held until the child is observed exited.
                if Self::process_still_alive(child) {
                    true
                } else {
                    let _ = owned_slot.lock().unwrap_or_else(|e| e.into_inner()).take();
                    false
                }
            }
            KillInner::CancelOnly { .. } => false,
        }
    }

    fn process_still_alive(child: &Mutex<Option<tokio::process::Child>>) -> bool {
        let mut guard = child.lock().unwrap_or_else(|e| e.into_inner());
        match guard.as_mut() {
            Some(c) => match c.try_wait() {
                Ok(None) => true,
                Ok(Some(_)) => {
                    // Reaped — drop the Child so Drop does not wait again.
                    let _ = guard.take();
                    false
                }
                Err(_) => true, // fail-closed: treat as still owned
            },
            None => false,
        }
    }

    /// True when this handle owns an OS process (ProcessIsolated).
    pub fn is_process_isolated(&self) -> bool {
        matches!(&*self.inner, KillInner::Process { .. })
    }

    /// OS PID of a live ProcessIsolated child, if still owned.
    ///
    /// Used by sacrificial proofs (D-048) to assert kill/reap without ambient heuristics.
    pub fn os_pid(&self) -> Option<u32> {
        match &*self.inner {
            KillInner::Process { child, .. } => {
                let guard = child.lock().unwrap_or_else(|e| e.into_inner());
                guard.as_ref().and_then(|c| c.id())
            }
            KillInner::CancelOnly { .. } => None,
        }
    }

    /// True when the body is driven inline on the caller task (no nested JoinHandle).
    pub fn is_cancel_only(&self) -> bool {
        matches!(&*self.inner, KillInner::CancelOnly { .. })
    }
}

/// Handle returned from [`ToolHandler::start`].
pub struct LinkedToolExecutionHandle {
    /// Stable execution id for this start.
    pub execution_id: ToolExecutionId,
    /// Cancellation control.
    pub control: ToolExecutionControl,
    /// Exactly-once completion.
    pub completion: ToolExecutionCompletion,
    /// Optional kill handle for escalate-after-grace (D-024).
    pub kill: Option<ToolKillHandle>,
    /// When `Some`, the dispatcher MUST poll this on the current task (M5.4).
    /// Completes by sending on [`Self::completion`]. No ambient `tokio::spawn`.
    pub drive: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
}

impl std::fmt::Debug for LinkedToolExecutionHandle {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("LinkedToolExecutionHandle")
            .field("execution_id", &self.execution_id)
            .field("control", &self.control)
            .field("completion", &self.completion)
            .field("kill", &self.kill)
            .field("drive", &self.drive.as_ref().map(|_| "<drive>"))
            .finish()
    }
}

/// Handler that completes immediately from a synchronous function.
pub struct ImmediateToolHandler<F> {
    f: F,
}

impl<F> ImmediateToolHandler<F>
where
    F: Fn(ToolCall, ToolCallContext) -> Result<ToolCompletion, ToolStartError> + Send + Sync,
{
    /// Construct from a function.
    pub fn new(f: F) -> Self {
        Self { f }
    }
}

impl<F> ToolHandler for ImmediateToolHandler<F>
where
    F: Fn(ToolCall, ToolCallContext) -> Result<ToolCompletion, ToolStartError> + Send + Sync,
{
    fn start(
        &self,
        call: ToolCall,
        context: ToolCallContext,
    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
        let completion = (self.f)(call, context)?;
        let (tx, rx) = oneshot::channel();
        let _ = tx.send(completion);
        Ok(LinkedToolExecutionHandle {
            execution_id: ToolExecutionId::generate(),
            control: ToolExecutionControl::new(),
            completion: ToolExecutionCompletion::new(rx),
            kill: None,
            drive: None,
        })
    }
}

type BoxFut = Pin<Box<dyn Future<Output = ToolCompletion> + Send>>;

/// Handler that runs an async body with abortable cancellation.
///
/// M5.4: body is returned as [`LinkedToolExecutionHandle::drive`] and polled on
/// the caller's task (the supervised ToolWorker dispatch path). No ambient
/// `tokio::spawn`. Cancel via [`ToolKillHandle::cancel_only`].
pub struct AsyncToolHandler<F> {
    f: F,
}

impl<F> AsyncToolHandler<F>
where
    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync,
{
    /// Construct from a function that returns a boxed future.
    pub fn new(f: F) -> Self {
        Self { f }
    }
}

impl<F> ToolHandler for AsyncToolHandler<F>
where
    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync,
{
    fn start(
        &self,
        call: ToolCall,
        context: ToolCallContext,
    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
        let control = ToolExecutionControl::new();
        let control_body = control.clone();
        let fut = (self.f)(call, context, control_body.clone());
        let (tx, rx) = oneshot::channel();
        // Drive inline on the dispatcher/ToolWorker task — Law 23 / M5.4.
        let drive = Box::pin(async move {
            tokio::select! {
                biased;
                _ = control_body.cancelled() => {
                    let _ = tx.send(ToolCompletion::RuntimeFailed(
                        monoloop_contracts::ToolRuntimeError::TerminationFailed,
                    ));
                }
                result = fut => {
                    let _ = tx.send(result);
                }
            }
        });
        let kill = ToolKillHandle::cancel_only(control.clone());
        Ok(LinkedToolExecutionHandle {
            execution_id: ToolExecutionId::generate(),
            control,
            completion: ToolExecutionCompletion::new(rx),
            kill: Some(kill),
            drive: Some(drive),
        })
    }

    fn supports_abort(&self) -> bool {
        true
    }

    fn runtime_owns_abortable_drive(&self) -> bool {
        true
    }
}

impl<F> abortable_seal::Sealed for AsyncToolHandler<F> where
    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync
{
}

impl<F> AbortableAtYieldHandler for AsyncToolHandler<F> where
    F: Fn(ToolCall, ToolCallContext, ToolExecutionControl) -> BoxFut + Send + Sync
{
}

/// Stubborn in-process worker for AbortableAtYield / legacy D-024 fixtures.
///
/// **Not** ProcessIsolated: termination is cancel + dropping the inline drive
/// (abort-at-yield of the caller task). [`Self::os_process_isolated`] is false.
/// Prefer [`super::process_tool::ProcessIsolatedToolHandler`] for real OS kill.
pub struct IsolatedKillableToolHandler<F> {
    f: F,
}

impl<F> IsolatedKillableToolHandler<F>
where
    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync,
{
    /// Construct from a function that returns a boxed future.
    pub fn new(f: F) -> Self {
        Self { f }
    }
}

impl<F> ToolHandler for IsolatedKillableToolHandler<F>
where
    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync,
{
    fn start(
        &self,
        call: ToolCall,
        context: ToolCallContext,
    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
        let control = ToolExecutionControl::new();
        let control_body = control.clone();
        let fut = (self.f)(call, context);
        let (tx, rx) = oneshot::channel();
        // Inline drive: ignores cooperative cancel unless the body polls it;
        // deadline path drops this future (abort-at-yield of the caller task).
        let drive = Box::pin(async move {
            let _ = control_body;
            let result = fut.await;
            let _ = tx.send(result);
        });
        let kill = ToolKillHandle::cancel_only(control.clone());
        Ok(LinkedToolExecutionHandle {
            execution_id: ToolExecutionId::generate(),
            control,
            completion: ToolExecutionCompletion::new(rx),
            kill: Some(kill),
            drive: Some(drive),
        })
    }

    fn supports_abort(&self) -> bool {
        // Abort-at-yield of the caller/dispatch task — not OS isolation.
        true
    }

    fn supports_isolated_kill(&self) -> bool {
        // D-043: Tokio abort must not satisfy ProcessIsolated registration.
        false
    }

    fn runtime_owns_abortable_drive(&self) -> bool {
        true
    }
}

impl<F> abortable_seal::Sealed for IsolatedKillableToolHandler<F> where
    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync
{
}

impl<F> AbortableAtYieldHandler for IsolatedKillableToolHandler<F> where
    F: Fn(ToolCall, ToolCallContext) -> BoxFut + Send + Sync
{
}

/// Handler that always fails at start (tests).
#[derive(Debug, Default)]
pub struct StartFailHandler {
    /// Rejection message.
    pub reason: &'static str,
}

impl ToolHandler for StartFailHandler {
    fn start(
        &self,
        _call: ToolCall,
        _context: ToolCallContext,
    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
        Err(ToolStartError::Rejected(self.reason))
    }
}

/// Handler that panics on start (tests).
#[derive(Debug, Default)]
pub struct PanicOnStartHandler;

impl ToolHandler for PanicOnStartHandler {
    fn start(
        &self,
        _call: ToolCall,
        _context: ToolCallContext,
    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
        panic!("deliberate tool panic");
    }
}

/// Handler whose completion is never sent (tests).
#[derive(Debug, Default)]
pub struct LostCompletionHandler;

impl ToolHandler for LostCompletionHandler {
    fn start(
        &self,
        _call: ToolCall,
        _context: ToolCallContext,
    ) -> Result<LinkedToolExecutionHandle, ToolStartError> {
        let (tx, rx) = oneshot::channel();
        drop(tx);
        Ok(LinkedToolExecutionHandle {
            execution_id: ToolExecutionId::generate(),
            control: ToolExecutionControl::new(),
            completion: ToolExecutionCompletion::new(rx),
            kill: None,
            drive: None,
        })
    }
}