Skip to main content

monoloop_loop/transaction/
dispatcher.rs

1//! Single validated execution path for linked tools (MCP and model share this).
2
3use super::owned_process_registry::OwnedProcessRegistry;
4use super::resolved_tools::ResolvedToolSet;
5use super::tool_capacity::{SharedToolCapacity, ToolPermit, TransactionToolCapacity};
6use super::tool_handler::{OwnedProcessLease, ToolExecutionControl, ToolKillHandle};
7use std::sync::atomic::AtomicU32;
8
9/// Runtime-scoped orphaned tool permits (§22.4 capacity honesty / M5.4).
10///
11/// Shared across dispatchers of one runtime (Law 8 — not process-global).
12/// **Not a join vault:** `TaskSupervisor` owns joins. This set only holds
13/// `ToolPermit`s (and optional ProcessIsolated leases) after cooperative
14/// non-ack / missing-kill / mid-drop ProcessIsolated paths. Quiesce releases
15/// all orphans so `Stopped` is not permanently blocked.
16///
17pub struct OrphanToolPermitSet {
18    orphans: std::sync::Mutex<Vec<OrphanPermit>>,
19}
20
21struct OrphanPermit {
22    /// Held until entry Drop / quiesce clear — releases SharedToolCapacity.
23    #[allow(dead_code)]
24    permit: ToolPermit,
25    /// Keeps `owned_processes` honest while a ProcessIsolated child may still exit.
26    #[allow(dead_code)]
27    process_lease: Option<OwnedProcessLease>,
28}
29
30/// Deprecated name for [`OrphanToolPermitSet`] (M5.4 delete-vaults).
31///
32/// This is **not** a join vault. Prefer [`OrphanToolPermitSet`].
33impl OrphanToolPermitSet {
34    /// Create an empty orphan-permit set.
35    pub fn new() -> Self {
36        Self {
37            orphans: std::sync::Mutex::new(Vec::new()),
38        }
39    }
40
41    /// Hold a permit with no killable join (missing kill / cooperative non-ack).
42    fn park_orphan_permit(&self, permit: ToolPermit) {
43        self.park_orphan_permit_with_lease(permit, None);
44    }
45
46    /// Orphan permit plus optional ProcessIsolated owned-process lease.
47    fn park_orphan_permit_with_lease(
48        &self,
49        permit: ToolPermit,
50        process_lease: Option<OwnedProcessLease>,
51    ) {
52        let mut orphans = self.orphans.lock().unwrap_or_else(|e| e.into_inner());
53        orphans.push(OrphanPermit {
54            permit,
55            process_lease,
56        });
57    }
58
59    /// Quiesce step: release all orphan permits (capacity + process leases).
60    ///
61    /// Returns `0` — orphans never block `Stopped` after quiesce (M5.4).
62    pub fn shutdown_progress(&self) -> usize {
63        let mut orphans = self.orphans.lock().unwrap_or_else(|e| e.into_inner());
64        orphans.clear();
65        0
66    }
67
68    /// Number of orphan permits still held.
69    pub fn pending_permits(&self) -> usize {
70        self.orphans.lock().unwrap_or_else(|e| e.into_inner()).len()
71    }
72
73    /// True when no orphan permits remain.
74    pub fn is_empty(&self) -> bool {
75        self.pending_count() == 0
76    }
77
78    /// Number of orphan permit entries.
79    pub fn pending_count(&self) -> usize {
80        self.pending_permits()
81    }
82}
83
84impl Default for OrphanToolPermitSet {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl Drop for OrphanToolPermitSet {
91    fn drop(&mut self) {
92        // Release remaining orphans with this Arc (no process-global transfer).
93        let _ = std::mem::take(self.orphans.get_mut().unwrap_or_else(|e| e.into_inner()));
94    }
95}
96
97use super::validation::{
98    validate_tool_completion, validate_tool_input, InputValidationFailure, DEFAULT_MAX_JSON_DEPTH,
99};
100use monoloop_contracts::{
101    CanonicalToolError, CanonicalToolOutput, CanonicalToolResult, CanonicalToolResultOutcome,
102    ExchangeId, SessionKey, ToolActionId, ToolCall, ToolCallContext, ToolCompletion,
103    ToolExecutionClass, ToolId, ToolLifecycleEvent, ToolName, ToolRuntimeError, ToolStartError,
104    TransactionId,
105};
106use std::future::Future;
107use std::panic::{catch_unwind, AssertUnwindSafe};
108use std::pin::Pin;
109use std::sync::Arc;
110use std::time::{Duration, Instant};
111
112/// Request to dispatch one complete tool call through the linked handler path.
113#[derive(Clone, Debug)]
114pub struct DispatchRequest {
115    /// Exchange owning this call (model path); MCP may synthesize one.
116    pub exchange_id: ExchangeId,
117    /// Internal action id.
118    pub tool_action_id: ToolActionId,
119    /// Public tool name as requested.
120    pub tool_name: ToolName,
121    /// Provider correlation id preserved exactly.
122    pub provider_tool_call_id: String,
123    /// Model-declared order.
124    pub request_ordinal: u32,
125    /// Complete JSON argument payload.
126    pub arguments_json: String,
127    /// Absolute transaction deadline — tool budget is `min(tool_limit, remaining)`.
128    /// Use a far-future Instant when the caller has no transaction Instant.
129    pub transaction_deadline: Instant,
130}
131
132/// Outcome of a dispatch attempt.
133#[derive(Clone, Debug)]
134pub enum DispatchOutcome {
135    /// Canonical success or declared domain failure (continuation product).
136    Canonical {
137        /// Validated result.
138        result: CanonicalToolResult,
139        /// Lifecycle events produced (Started + Completed).
140        lifecycle: Vec<ToolLifecycleEvent>,
141    },
142    /// Invalid/disallowed arguments — rejected tool result, not transaction failure.
143    Rejected {
144        /// Action id.
145        tool_action_id: ToolActionId,
146        /// Safe reason code.
147        code: &'static str,
148        /// Safe message.
149        message: String,
150        /// Lifecycle (Started may be omitted when never accepted).
151        lifecycle: Vec<ToolLifecycleEvent>,
152    },
153    /// Handler/runtime failure — selects `ToolExchangeFailed` when policy requires.
154    RuntimeFailed {
155        /// Action id.
156        tool_action_id: ToolActionId,
157        /// Tool id when known.
158        tool_id: Option<ToolId>,
159        /// Safe failure code.
160        code: String,
161        /// Lifecycle events (may include Started + RuntimeFailed).
162        lifecycle: Vec<ToolLifecycleEvent>,
163    },
164}
165
166/// Capacity and payload bounds for one transaction dispatcher (D-015).
167#[derive(Clone, Copy, Debug)]
168pub struct DispatcherLimits {
169    /// Max concurrent tool executions for this transaction.
170    pub max_concurrent_tools: usize,
171    /// Max queued tool starts for this transaction.
172    pub max_queued_tools: usize,
173    /// Transaction-wide payload cap; applied as min with per-tool limit.
174    pub max_tool_payload_bytes: usize,
175    /// Transaction-wide output cap; applied as min with per-tool limit.
176    pub max_tool_output_bytes: usize,
177}
178
179impl Default for DispatcherLimits {
180    fn default() -> Self {
181        Self {
182            max_concurrent_tools: 16,
183            max_queued_tools: 64,
184            max_tool_payload_bytes: usize::MAX,
185            max_tool_output_bytes: usize::MAX,
186        }
187    }
188}
189
190/// Transaction-owned dispatcher: allowlist, validation, capacity, handler, output check.
191pub struct TransactionToolDispatcher {
192    transaction_id: TransactionId,
193    /// Authoritative after claim; may start provisional on create (D-026).
194    session_key: std::sync::Mutex<SessionKey>,
195    tools: ResolvedToolSet,
196    capacity: Arc<TransactionToolCapacity>,
197    /// Runtime-scoped spill for unfinished joins (D-028 / Law 8).
198    tool_spill: Arc<OrphanToolPermitSet>,
199    /// Runtime-scoped live ProcessIsolated child count (§18.2 snapshot).
200    owned_processes: Arc<AtomicU32>,
201    /// ProcessIsolated children retained until OS exit (D-048).
202    process_registry: Arc<OwnedProcessRegistry>,
203    /// Transaction-wide payload cap (D-015); applied as min with per-tool limit.
204    max_tool_payload_bytes: usize,
205    /// Transaction-wide output cap (D-015); applied as min with per-tool limit.
206    max_tool_output_bytes: usize,
207    max_error_message_bytes: usize,
208    max_json_depth: u32,
209}
210
211impl TransactionToolDispatcher {
212    /// Build a dispatcher for one admitted transaction (local spill for unit tests).
213    pub fn new(
214        transaction_id: TransactionId,
215        session_key: SessionKey,
216        tools: ResolvedToolSet,
217        shared_capacity: Arc<SharedToolCapacity>,
218        max_concurrent_tools: usize,
219        max_queued_tools: usize,
220    ) -> Arc<Self> {
221        Self::with_limits(
222            transaction_id,
223            session_key,
224            tools,
225            shared_capacity,
226            DispatcherLimits {
227                max_concurrent_tools,
228                max_queued_tools,
229                max_tool_payload_bytes: usize::MAX,
230                max_tool_output_bytes: usize::MAX,
231            },
232        )
233    }
234
235    /// Build with explicit concurrency and payload/output caps (D-015).
236    pub fn with_limits(
237        transaction_id: TransactionId,
238        session_key: SessionKey,
239        tools: ResolvedToolSet,
240        shared_capacity: Arc<SharedToolCapacity>,
241        limits: DispatcherLimits,
242    ) -> Arc<Self> {
243        Self::with_limits_and_spill(
244            transaction_id,
245            session_key,
246            tools,
247            shared_capacity,
248            limits,
249            Arc::new(OrphanToolPermitSet::new()),
250            Arc::new(AtomicU32::new(0)),
251            Arc::new(OwnedProcessRegistry::new()),
252        )
253    }
254
255    /// Build with a runtime-shared tool spill (production RuntimeOwner path).
256    pub fn with_runtime_spill(
257        transaction_id: TransactionId,
258        session_key: SessionKey,
259        tools: ResolvedToolSet,
260        shared_capacity: Arc<SharedToolCapacity>,
261        tool_spill: Arc<OrphanToolPermitSet>,
262        limits: DispatcherLimits,
263    ) -> Arc<Self> {
264        Self::with_runtime_resources(
265            transaction_id,
266            session_key,
267            tools,
268            shared_capacity,
269            tool_spill,
270            Arc::new(AtomicU32::new(0)),
271            Arc::new(OwnedProcessRegistry::new()),
272            limits,
273        )
274    }
275
276    /// Build with runtime-shared spill, owned-process counter, and process registry.
277    #[allow(clippy::too_many_arguments)]
278    pub fn with_runtime_resources(
279        transaction_id: TransactionId,
280        session_key: SessionKey,
281        tools: ResolvedToolSet,
282        shared_capacity: Arc<SharedToolCapacity>,
283        tool_spill: Arc<OrphanToolPermitSet>,
284        owned_processes: Arc<AtomicU32>,
285        process_registry: Arc<OwnedProcessRegistry>,
286        limits: DispatcherLimits,
287    ) -> Arc<Self> {
288        Self::with_limits_and_spill(
289            transaction_id,
290            session_key,
291            tools,
292            shared_capacity,
293            limits,
294            tool_spill,
295            owned_processes,
296            process_registry,
297        )
298    }
299
300    /// Map transaction-wide tool caps from [`TransactionLimits`](monoloop_contracts::TransactionLimits).
301    pub fn limits_from_transaction(
302        limits: &monoloop_contracts::TransactionLimits,
303    ) -> DispatcherLimits {
304        DispatcherLimits {
305            max_concurrent_tools: limits.max_concurrent_tools_per_transaction.max(1),
306            max_queued_tools: limits.max_queued_tools_per_transaction.max(1),
307            max_tool_payload_bytes: limits.max_tool_payload_bytes.max(1),
308            max_tool_output_bytes: limits.max_tool_output_bytes.max(1),
309        }
310    }
311
312    /// Build with an explicit runtime-scoped tool spill (D-028).
313    #[allow(clippy::too_many_arguments)]
314    fn with_limits_and_spill(
315        transaction_id: TransactionId,
316        session_key: SessionKey,
317        tools: ResolvedToolSet,
318        shared_capacity: Arc<SharedToolCapacity>,
319        limits: DispatcherLimits,
320        tool_spill: Arc<OrphanToolPermitSet>,
321        owned_processes: Arc<AtomicU32>,
322        process_registry: Arc<OwnedProcessRegistry>,
323    ) -> Arc<Self> {
324        let capacity = TransactionToolCapacity::new(
325            shared_capacity,
326            limits.max_concurrent_tools,
327            limits.max_queued_tools,
328        );
329        for spec in tools.specs() {
330            capacity.configure_tool(spec.id.clone(), spec.limits.max_concurrent);
331        }
332        Arc::new(Self {
333            transaction_id,
334            session_key: std::sync::Mutex::new(session_key),
335            tools,
336            capacity,
337            tool_spill,
338            owned_processes,
339            process_registry,
340            max_tool_payload_bytes: limits.max_tool_payload_bytes.max(1),
341            max_tool_output_bytes: limits.max_tool_output_bytes.max(1),
342            max_error_message_bytes: 1024,
343            max_json_depth: DEFAULT_MAX_JSON_DEPTH,
344        })
345    }
346
347    /// Resolved tool set (encoder / MCP projection).
348    pub fn tools(&self) -> &ResolvedToolSet {
349        &self.tools
350    }
351
352    /// Transaction identity.
353    pub fn transaction_id(&self) -> TransactionId {
354        self.transaction_id
355    }
356
357    /// Session key (clone under lock).
358    pub fn session_key(&self) -> SessionKey {
359        self.session_key
360            .lock()
361            .unwrap_or_else(|e| e.into_inner())
362            .clone()
363    }
364
365    /// Replace provisional create key with the claimed authoritative key (D-026).
366    pub fn rebind_session(&self, session_key: SessionKey) {
367        *self.session_key.lock().unwrap_or_else(|e| e.into_inner()) = session_key;
368    }
369
370    /// Permits held by unfinished/orphaned tool workers (§22.4 observability).
371    pub fn vault_pending_permits(&self) -> usize {
372        self.tool_spill.pending_permits()
373    }
374
375    /// Runtime-scoped spill shared with the supervisor (when wired).
376    pub fn tool_spill(&self) -> &Arc<OrphanToolPermitSet> {
377        &self.tool_spill
378    }
379
380    /// Active tool executions in this transaction (bounded concurrency observation).
381    pub fn active_tools(&self) -> usize {
382        self.capacity.active()
383    }
384
385    /// Queued tool starts waiting for concurrency (bounded queue observation).
386    pub fn queued_tools(&self) -> usize {
387        self.capacity.queued()
388    }
389
390    /// Dispatch one call end-to-end.
391    pub async fn dispatch(self: &Arc<Self>, request: DispatchRequest) -> DispatchOutcome {
392        self.dispatch_with_cancel(request, None).await
393    }
394
395    /// Dispatch with an optional external cancel signal (D-028).
396    ///
397    /// When `cancel` is notified, the dispatcher runs the same termination path as
398    /// an execution deadline so the worker is cancel/kill/joined instead of detached.
399    pub async fn dispatch_with_cancel(
400        self: &Arc<Self>,
401        request: DispatchRequest,
402        cancel: Option<Arc<super::sticky_cancel::StickyCancel>>,
403    ) -> DispatchOutcome {
404        let action = request.tool_action_id.clone();
405
406        // Allowlist by public name.
407        let Some(resolved) = self.tools.get_by_name(&request.tool_name) else {
408            return DispatchOutcome::Rejected {
409                tool_action_id: action,
410                code: "tool_not_allowed",
411                message: "tool not in resolved set".into(),
412                lifecycle: vec![],
413            };
414        };
415        let tool_id = resolved.spec.id.clone();
416        let spec = resolved.spec.clone();
417        let handler = Arc::clone(&resolved.handler);
418
419        if !self.capacity.try_enqueue() {
420            return DispatchOutcome::Rejected {
421                tool_action_id: action,
422                code: "tool_queue_full",
423                message: "per-transaction tool queue full".into(),
424                lifecycle: vec![],
425            };
426        }
427
428        // Input validation before capacity acquire for execution.
429        // Effective payload limit is min(per-tool, transaction-wide) (D-015).
430        let max_payload = spec.limits.max_input_bytes.min(self.max_tool_payload_bytes);
431        let arguments = match validate_tool_input(
432            &request.arguments_json,
433            &spec.input_schema,
434            max_payload,
435            self.max_json_depth,
436        ) {
437            Ok(v) => v,
438            Err(f) => {
439                self.capacity.dequeue();
440                return reject_input(action, f);
441            }
442        };
443
444        // Wait briefly for concurrency (bounded spin/yield); fail closed if not acquired.
445        let permit = {
446            let mut acquired = None;
447            let deadline = Instant::now() + Duration::from_millis(50);
448            while Instant::now() < deadline {
449                if let Some(p) = self.capacity.try_acquire(&tool_id) {
450                    acquired = Some(p);
451                    break;
452                }
453                tokio::task::yield_now().await;
454            }
455            match acquired {
456                Some(p) => p,
457                None => {
458                    // try_acquire dequeues only on success; still queued.
459                    self.capacity.dequeue();
460                    return DispatchOutcome::Rejected {
461                        tool_action_id: action,
462                        code: "tool_capacity_exceeded",
463                        message: "tool concurrency capacity exceeded".into(),
464                        lifecycle: vec![],
465                    };
466                }
467            }
468        };
469        // RAII: mid-drop parks orphan capacity (not JoinHandles — M5.4).
470        let mut dispatch_guard = DispatchGuard {
471            permit: Some(permit),
472            kill: None,
473            process_registry: Arc::clone(&self.process_registry),
474        };
475
476        let mut lifecycle = vec![ToolLifecycleEvent::Started {
477            tool_action_id: action.clone(),
478            tool_id: tool_id.clone(),
479            tool_name: request.tool_name.clone(),
480            provider_tool_call_id: request.provider_tool_call_id.clone(),
481            request_ordinal: request.request_ordinal,
482        }];
483
484        let call = ToolCall {
485            tool_name: request.tool_name.clone(),
486            tool_id: tool_id.clone(),
487            provider_tool_call_id: request.provider_tool_call_id.clone(),
488            arguments,
489            request_ordinal: request.request_ordinal,
490        };
491        // Cap tool execution to the remaining transaction Instant (acceptance P1).
492        let now = Instant::now();
493        if now >= request.transaction_deadline {
494            drop(dispatch_guard);
495            return DispatchOutcome::RuntimeFailed {
496                tool_action_id: action,
497                tool_id: Some(tool_id),
498                code: "transaction_deadline_exceeded".into(),
499                lifecycle: vec![],
500            };
501        }
502        let tool_cap = now + spec.limits.execution_deadline;
503        let absolute_tool_deadline = tool_cap.min(request.transaction_deadline);
504        let context = ToolCallContext {
505            transaction_id: self.transaction_id,
506            session_key: self.session_key(),
507            exchange_id: Some(request.exchange_id),
508            tool_action_id: action.clone(),
509            tool_id: tool_id.clone(),
510            deadline: absolute_tool_deadline,
511        };
512
513        // Bounded execution: deadline / external cancel → grace → kill → join (D-024 / D-028).
514        let deadline = absolute_tool_deadline.saturating_duration_since(now);
515        let policy = spec.execution_class.clone();
516
517        // Structural termination support must be confirmed *before* start so a
518        // missing kill capability cannot leave an ignoring worker running (D-028 / D-050).
519        let supports_required_termination = match &policy {
520            // V2 §14.2: structural Abortable claim, not boolean-only supports_abort.
521            ToolExecutionClass::AbortableAtYield { .. } => {
522                handler.runtime_owns_abortable_drive() && handler.supports_abort()
523            }
524            // V2 §14.3: structural OS process claim, not boolean-only kill support.
525            ToolExecutionClass::ProcessIsolated { .. } => {
526                handler.os_process_isolated() && handler.supports_isolated_kill()
527            }
528            ToolExecutionClass::CooperativeInProcess { .. } => true,
529        };
530        if !supports_required_termination {
531            drop(dispatch_guard);
532            lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
533                tool_action_id: action.clone(),
534                tool_id: tool_id.clone(),
535                code: "missing_kill_handle".into(),
536            });
537            return DispatchOutcome::RuntimeFailed {
538                tool_action_id: action,
539                tool_id: Some(tool_id),
540                code: "missing_kill_handle".into(),
541                lifecycle,
542            };
543        }
544
545        let start_result = catch_unwind(AssertUnwindSafe(|| handler.start(call, context)));
546        let handle = match start_result {
547            Ok(Ok(h)) => h,
548            Ok(Err(ToolStartError::CapacityExceeded)) => {
549                drop(dispatch_guard);
550                return DispatchOutcome::Rejected {
551                    tool_action_id: action,
552                    code: "tool_capacity_exceeded",
553                    message: "handler capacity exceeded".into(),
554                    lifecycle,
555                };
556            }
557            Ok(Err(ToolStartError::Rejected(reason))) => {
558                drop(dispatch_guard);
559                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
560                    tool_action_id: action.clone(),
561                    tool_id: tool_id.clone(),
562                    code: "tool_start_rejected".into(),
563                });
564                return DispatchOutcome::RuntimeFailed {
565                    tool_action_id: action,
566                    tool_id: Some(tool_id),
567                    code: format!("start_rejected:{reason}"),
568                    lifecycle,
569                };
570            }
571            Err(_) => {
572                drop(dispatch_guard);
573                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
574                    tool_action_id: action.clone(),
575                    tool_id: tool_id.clone(),
576                    code: "tool_panicked".into(),
577                });
578                return DispatchOutcome::RuntimeFailed {
579                    tool_action_id: action,
580                    tool_id: Some(tool_id),
581                    code: "panicked".into(),
582                    lifecycle,
583                };
584            }
585        };
586
587        let control = handle.control.clone();
588        dispatch_guard.kill = handle.kill.clone();
589        let kill = dispatch_guard.kill.clone();
590        // Count live ProcessIsolated children for ShutdownSnapshot.owned_processes.
591        if let Some(ref k) = kill {
592            if k.is_process_isolated() {
593                k.register_owned_process(Arc::clone(&self.owned_processes));
594            }
595        }
596        // Post-start invariant: Abortable needs CancelOnly + runtime-owned drive;
597        // ProcessIsolated needs an OS-process kill handle (not Tokio abort).
598        let ownership_ok = match &policy {
599            ToolExecutionClass::AbortableAtYield { .. } => {
600                kill.as_ref().is_some_and(|k| k.is_cancel_only()) && handle.drive.is_some()
601            }
602            ToolExecutionClass::ProcessIsolated { .. } => {
603                kill.as_ref().is_some_and(|k| k.is_process_isolated()) && handle.drive.is_some()
604            }
605            ToolExecutionClass::CooperativeInProcess { .. } => true,
606        };
607        if !ownership_ok {
608            // Handler claimed abort/kill support but returned no ToolKillHandle —
609            // we do not own the real worker. Never fabricate a completion waiter
610            // whose abort would release the permit while work may still run.
611            control.cancel();
612            let completion = handle.completion;
613            let wait = completion.wait();
614            tokio::pin!(wait);
615            match tokio::time::timeout(Duration::from_millis(200), &mut wait).await {
616                Ok(_) => {
617                    // Completion already observed — safe to release capacity.
618                    dispatch_guard.release_if_idle();
619                }
620                Err(_) => {
621                    // Pending work without a killable join: orphan the permit
622                    // (held until runtime shutdown). Do not park an abortable waiter.
623                    if let Some(permit) = dispatch_guard.permit.take() {
624                        self.tool_spill.park_orphan_permit(permit);
625                    }
626                }
627            }
628            lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
629                tool_action_id: action.clone(),
630                tool_id: tool_id.clone(),
631                code: "missing_kill_handle".into(),
632            });
633            return DispatchOutcome::RuntimeFailed {
634                tool_action_id: action,
635                tool_id: Some(tool_id),
636                code: "missing_kill_handle".into(),
637                lifecycle,
638            };
639        }
640        let mut drive = handle.drive;
641        let wait = handle.completion.wait();
642        tokio::pin!(wait);
643        let cancel_fut = async {
644            if let Some(n) = cancel.as_ref() {
645                n.cancelled().await;
646            } else {
647                std::future::pending::<()>().await;
648            }
649        };
650        let completion = if let Some(drive_fut) = drive.take() {
651            // M5.4: poll handler body on this task (supervised ToolWorker / test await).
652            tokio::pin!(drive_fut);
653            tokio::select! {
654                biased;
655                c = &mut wait => c,
656                _ = &mut drive_fut => wait.await,
657                _ = cancel_fut => {
658                    await_tool_termination_driven(
659                        &mut wait,
660                        &mut drive_fut,
661                        &control,
662                        kill.as_ref(),
663                        &policy,
664                    )
665                    .await
666                }
667                _ = tokio::time::sleep(deadline) => {
668                    await_tool_termination_driven(
669                        &mut wait,
670                        &mut drive_fut,
671                        &control,
672                        kill.as_ref(),
673                        &policy,
674                    )
675                    .await
676                }
677            }
678        } else {
679            tokio::select! {
680                biased;
681                c = &mut wait => c,
682                _ = cancel_fut => {
683                    await_tool_termination(&mut wait, &control, kill.as_ref(), &policy).await
684                }
685                _ = tokio::time::sleep(deadline) => {
686                    await_tool_termination(&mut wait, &control, kill.as_ref(), &policy).await
687                }
688            }
689        };
690        // Join worker within a short bound; if still pending, Drop parks orphan permit.
691        // §22.4 cooperative non-ack: keep capacity held for cancel_only inline drive
692        // that timed out without completion.
693        let cooperative_deadline =
694            matches!(policy, ToolExecutionClass::CooperativeInProcess { .. })
695                && matches!(
696                    &completion,
697                    ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded)
698                );
699        if let Some(ref k) = dispatch_guard.kill {
700            let _ = k.join_timeout(Duration::from_millis(50)).await;
701            if cooperative_deadline && k.is_cancel_only() {
702                // Non-ack cancel_only/drive has no join — orphan the permit explicitly.
703                if let Some(permit) = dispatch_guard.permit.take() {
704                    self.tool_spill.park_orphan_permit(permit);
705                }
706            }
707        } else if cooperative_deadline {
708            if let Some(permit) = dispatch_guard.permit.take() {
709                self.tool_spill.park_orphan_permit(permit);
710            }
711        }
712        dispatch_guard.release_if_idle();
713
714        let max_output = spec.limits.max_output_bytes.min(self.max_tool_output_bytes);
715        let validated = match validate_tool_completion(
716            completion,
717            &spec.output_contract,
718            max_output,
719            self.max_error_message_bytes,
720            self.max_json_depth,
721        ) {
722            Ok(c) => c,
723            Err(_) => {
724                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
725                    tool_action_id: action.clone(),
726                    tool_id: tool_id.clone(),
727                    code: "output_contract_violated".into(),
728                });
729                return DispatchOutcome::RuntimeFailed {
730                    tool_action_id: action,
731                    tool_id: Some(tool_id),
732                    code: "output_contract_violated".into(),
733                    lifecycle,
734                };
735            }
736        };
737
738        match validated {
739            ToolCompletion::Succeeded(output) => {
740                let result = CanonicalToolResult {
741                    transaction_id: self.transaction_id,
742                    session_key: self.session_key(),
743                    exchange_id: request.exchange_id,
744                    tool_action_id: action.clone(),
745                    tool_id: tool_id.clone(),
746                    provider_tool_call_id: request.provider_tool_call_id,
747                    request_ordinal: request.request_ordinal,
748                    outcome: CanonicalToolResultOutcome::Succeeded(output),
749                };
750                lifecycle.push(ToolLifecycleEvent::Completed {
751                    result: result.clone(),
752                });
753                DispatchOutcome::Canonical { result, lifecycle }
754            }
755            ToolCompletion::DomainFailed(err) => {
756                let result = CanonicalToolResult {
757                    transaction_id: self.transaction_id,
758                    session_key: self.session_key(),
759                    exchange_id: request.exchange_id,
760                    tool_action_id: action.clone(),
761                    tool_id: tool_id.clone(),
762                    provider_tool_call_id: request.provider_tool_call_id,
763                    request_ordinal: request.request_ordinal,
764                    outcome: CanonicalToolResultOutcome::DomainFailed(err),
765                };
766                lifecycle.push(ToolLifecycleEvent::Completed {
767                    result: result.clone(),
768                });
769                DispatchOutcome::Canonical { result, lifecycle }
770            }
771            ToolCompletion::RuntimeFailed(e) => {
772                let code = match e {
773                    ToolRuntimeError::Panicked => "panicked",
774                    ToolRuntimeError::CompletionLost => "completion_lost",
775                    ToolRuntimeError::OutputContractViolated => "output_contract_violated",
776                    ToolRuntimeError::TerminationFailed => "termination_failed",
777                    ToolRuntimeError::DeadlineExceeded => "deadline_exceeded",
778                };
779                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
780                    tool_action_id: action.clone(),
781                    tool_id: tool_id.clone(),
782                    code: code.into(),
783                });
784                DispatchOutcome::RuntimeFailed {
785                    tool_action_id: action,
786                    tool_id: Some(tool_id),
787                    code: code.into(),
788                    lifecycle,
789                }
790            }
791        }
792    }
793}
794
795/// After execution deadline: cooperative cancel, optional grace, then kill+join (D-024).
796async fn await_tool_termination(
797    wait: &mut Pin<&mut impl Future<Output = ToolCompletion>>,
798    control: &ToolExecutionControl,
799    kill: Option<&ToolKillHandle>,
800    policy: &ToolExecutionClass,
801) -> ToolCompletion {
802    control.cancel();
803    let join_grace = Duration::from_millis(200);
804    match policy {
805        ToolExecutionClass::AbortableAtYield { .. } => {
806            if let Some(k) = kill {
807                k.kill();
808                // Bounded join only — never await unboundedly here (actor cleanup
809                // may drop this future). Unfinished joins stay owned for
810                // DispatchGuard to vault with the permit.
811                if k.join_timeout(join_grace).await.is_err() {
812                    return ToolCompletion::RuntimeFailed(ToolRuntimeError::TerminationFailed);
813                }
814            }
815            match tokio::time::timeout(join_grace, wait).await {
816                Ok(c) => c,
817                Err(_) => ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded),
818            }
819        }
820        ToolExecutionClass::CooperativeInProcess { grace } => {
821            match tokio::time::timeout(*grace, &mut *wait).await {
822                Ok(c) => c,
823                Err(_) => ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded),
824            }
825        }
826        ToolExecutionClass::ProcessIsolated {
827            grace,
828            kill_deadline,
829        } => match tokio::time::timeout(*grace, &mut *wait).await {
830            Ok(c) => c,
831            Err(_) => {
832                if let Some(k) = kill {
833                    k.kill();
834                    if k.join_timeout(*kill_deadline).await.is_err() {
835                        return ToolCompletion::RuntimeFailed(ToolRuntimeError::TerminationFailed);
836                    }
837                }
838                match tokio::time::timeout(*kill_deadline, wait).await {
839                    Ok(c) => c,
840                    Err(_) => ToolCompletion::RuntimeFailed(ToolRuntimeError::TerminationFailed),
841                }
842            }
843        },
844    }
845}
846
847/// Termination path when the handler body is driven inline on this task (M5.4).
848async fn await_tool_termination_driven(
849    wait: &mut Pin<&mut impl Future<Output = ToolCompletion>>,
850    drive: &mut Pin<&mut impl Future<Output = ()>>,
851    control: &ToolExecutionControl,
852    kill: Option<&ToolKillHandle>,
853    policy: &ToolExecutionClass,
854) -> ToolCompletion {
855    control.cancel();
856    match policy {
857        ToolExecutionClass::ProcessIsolated {
858            grace,
859            kill_deadline,
860        } => {
861            // Cooperative cancel is best-effort; escalate to OS kill after grace.
862            tokio::select! {
863                biased;
864                c = &mut *wait => c,
865                _ = &mut *drive => wait.await,
866                _ = tokio::time::sleep(*grace) => {
867                    if let Some(k) = kill {
868                        k.kill();
869                    }
870                    tokio::select! {
871                        biased;
872                        c = &mut *wait => c,
873                        _ = &mut *drive => wait.await,
874                        _ = tokio::time::sleep(*kill_deadline) => {
875                            if let Some(k) = kill {
876                                let _ = k.join_timeout(Duration::from_millis(50)).await;
877                            }
878                            ToolCompletion::RuntimeFailed(ToolRuntimeError::TerminationFailed)
879                        }
880                    }
881                }
882            }
883        }
884        ToolExecutionClass::AbortableAtYield { grace }
885        | ToolExecutionClass::CooperativeInProcess { grace } => {
886            if let Some(k) = kill {
887                k.kill();
888            }
889            let join_grace = Duration::from_millis(200).max(*grace);
890            // Keep polling drive so cancel_only bodies can observe cancel and complete.
891            tokio::select! {
892                biased;
893                c = &mut *wait => c,
894                _ = &mut *drive => wait.await,
895                _ = tokio::time::sleep(join_grace) => {
896                    if let Some(k) = kill {
897                        let _ = k.join_timeout(Duration::from_millis(50)).await;
898                    }
899                    // Dropping `drive` stops inline Abortable work at the next .await.
900                    ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded)
901                }
902            }
903        }
904    }
905}
906
907/// Holds the tool permit and optional kill handle for the duration of dispatch.
908/// On drop, unfinished ProcessIsolated children transfer to
909/// [`OwnedProcessRegistry`] until OS exit is observed (D-048). Cooperative
910/// non-ack capacity parks as orphan permits (M5.4).
911struct DispatchGuard {
912    permit: Option<ToolPermit>,
913    kill: Option<ToolKillHandle>,
914    process_registry: Arc<OwnedProcessRegistry>,
915}
916
917impl DispatchGuard {
918    /// Release permit only when no unfinished worker join remains.
919    fn release_if_idle(&mut self) {
920        if self.kill.as_ref().is_some_and(ToolKillHandle::has_join) {
921            return;
922        }
923        self.kill.take();
924        drop(self.permit.take());
925    }
926}
927
928impl Drop for DispatchGuard {
929    fn drop(&mut self) {
930        if let Some(k) = self.kill.take() {
931            k.kill(); // cancel_only / OS kill
932            if k.is_process_isolated() && k.has_join() {
933                // D-048: retain the real Child (via ToolKillHandle) until reap —
934                // not merely an owned_processes counter lease.
935                let permit = self.permit.take();
936                self.process_registry.park(k, permit);
937                return;
938            }
939        }
940        drop(self.permit.take());
941    }
942}
943
944fn reject_input(action: ToolActionId, f: InputValidationFailure) -> DispatchOutcome {
945    let (code, message) = match f {
946        InputValidationFailure::OversizedInput => ("oversized_input", "tool input exceeds limit"),
947        InputValidationFailure::InvalidJson => {
948            ("invalid_json", "tool arguments are not valid JSON")
949        }
950        InputValidationFailure::DepthExceeded => ("json_depth_exceeded", "tool arguments too deep"),
951        InputValidationFailure::SchemaInvalid => {
952            ("schema_invalid", "tool arguments fail input schema")
953        }
954    };
955    // Rejected path still yields a domain-style rejection result shape for tests.
956    let _ = CanonicalToolError::try_new(code, message, None, 256);
957    let _ = CanonicalToolOutput::Text(String::new());
958    DispatchOutcome::Rejected {
959        tool_action_id: action,
960        code,
961        message: message.into(),
962        lifecycle: vec![],
963    }
964}