Skip to main content

salvor_server/
dispatch.rs

1//! The one place that maps a run's derived state to the verb that continues
2//! it. Both the CLI's `resume` command and the server's resume endpoint call
3//! [`classify`], so the two surfaces cannot drift on what a given state means.
4//!
5//! The mapping is exactly this continuation rule:
6//!
7//! - a **parked** run (suspended, or budget-exceeded) resumes with input;
8//! - a **crashed** run (running, or interrupted mid model or tool step)
9//!   recovers with no input;
10//! - a run that **needs reconciliation** is refused, and its recorded write
11//!   intent is the evidence a human resolves it with;
12//! - a **finished** run (completed, failed, or operator-abandoned) is reported
13//!   and left alone;
14//! - an **empty** log is not a run at all.
15//!
16//! This module holds only the decision, not the effect: it does no IO, drives
17//! nothing, and prints nothing. The caller acts on the [`Disposition`] in the
18//! way its surface calls for (an exit code and a report for the CLI, an HTTP
19//! status and a JSON body for the server).
20
21use salvor_core::{PendingCall, RunState, RunStatus, UnresolvedWrite};
22
23/// Whether a resume should validate and expect an input, or run with none.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ResumeKind {
26    /// The run suspended on a tool; the input is validated against the
27    /// recorded suspension schema.
28    Suspension,
29    /// The run crossed a budget; the input is validated against the
30    /// budget-extension shape.
31    Budget,
32}
33
34/// What to do with a run, decided from its derived state alone.
35#[derive(Debug, Clone, PartialEq)]
36pub enum Disposition {
37    /// The run is parked and resumes with a validated input.
38    Resume(ResumeKind),
39    /// The run crashed mid-step and recovers with no input.
40    Recover,
41    /// The run needs human reconciliation. Carries the dangling write intent
42    /// so the caller can show it as evidence.
43    Reconcile(PendingCall),
44    /// The run already finished with this output.
45    Completed(serde_json::Value),
46    /// The run already failed with this error.
47    Failed(String),
48    /// The run was abandoned by an operator. A terminal resting state, reported
49    /// and left alone exactly as completed or failed is, distinct from failure.
50    Abandoned {
51        /// The operator's optional note.
52        reason: Option<String>,
53        /// The write intent left unsettled when a needs-reconciliation run was
54        /// abandoned, when there was one.
55        unresolved_write: Option<UnresolvedWrite>,
56    },
57    /// The log is empty; there is no run to continue.
58    NotStarted,
59}
60
61/// Maps a derived [`RunState`] to its [`Disposition`].
62#[must_use]
63pub fn classify(state: &RunState) -> Disposition {
64    match &state.status {
65        RunStatus::Suspended { .. } => Disposition::Resume(ResumeKind::Suspension),
66        RunStatus::BudgetExceeded { .. } => Disposition::Resume(ResumeKind::Budget),
67        RunStatus::Running | RunStatus::AwaitingModel | RunStatus::AwaitingTool => {
68            Disposition::Recover
69        }
70        RunStatus::NeedsReconciliation => {
71            // A needs-reconciliation state always carries the pending write
72            // intent whose completion is missing; if it somehow did not, there
73            // is still nothing to drive, so recovery would refuse it too.
74            match &state.pending_call {
75                Some(pending @ PendingCall::Tool { .. }) => Disposition::Reconcile(pending.clone()),
76                _ => Disposition::Recover,
77            }
78        }
79        RunStatus::Completed { output } => Disposition::Completed(output.clone()),
80        RunStatus::Failed { error } => Disposition::Failed(error.clone()),
81        RunStatus::Abandoned {
82            reason,
83            unresolved_write,
84        } => Disposition::Abandoned {
85            reason: reason.clone(),
86            unresolved_write: unresolved_write.clone(),
87        },
88        RunStatus::NotStarted => Disposition::NotStarted,
89    }
90}