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 **sleeping** run carries its deadline, and the caller decides against
11//!   its own clock: due, it re-drives like a crashed one; early, it is
12//!   refused and the instant is the evidence;
13//! - a run that **needs reconciliation** is refused, and its recorded write
14//!   intent is the evidence a human resolves it with;
15//! - a **finished** run (completed, failed, or operator-abandoned) is reported
16//!   and left alone;
17//! - an **empty** log is not a run at all.
18//!
19//! This module holds only the decision, not the effect: it does no IO, drives
20//! nothing, and prints nothing. The caller acts on the [`Disposition`] in the
21//! way its surface calls for (an exit code and a report for the CLI, an HTTP
22//! status and a JSON body for the server).
23
24use salvor_core::{PendingCall, RunState, RunStatus, UnresolvedWrite};
25use time::OffsetDateTime;
26
27/// Whether a resume should validate and expect an input, or run with none.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ResumeKind {
30    /// The run suspended on a tool; the input is validated against the
31    /// recorded suspension schema.
32    Suspension,
33    /// The run crossed a budget; the input is validated against the
34    /// budget-extension shape.
35    Budget,
36}
37
38/// What to do with a run, decided from its derived state alone.
39#[derive(Debug, Clone, PartialEq)]
40pub enum Disposition {
41    /// The run is parked and resumes with a validated input.
42    Resume(ResumeKind),
43    /// The run crashed mid-step and recovers with no input.
44    Recover,
45    /// The run is parked on a durable timer. Carries the recorded wake instant,
46    /// which is both the evidence a refusal names and the value the caller
47    /// tests its clock against.
48    ///
49    /// The clock is not read here, and the disposition is not "recover" or
50    /// "refuse" on its own, because this module holds no clock and must not:
51    /// the same state means "drive it" to a waker whose sweep found the run
52    /// due and "refuse" to a person resuming it an hour early, and only the
53    /// caller knows which it is. Both surfaces compare `wake_at` against the
54    /// clock they already drive with, so neither can decide differently from
55    /// the [`RunCtx::await_wake`](salvor_runtime::RunCtx::await_wake) that
56    /// enforces the deadline inside the run.
57    Sleeping {
58        /// The instant the run's recorded `SleepStarted` said it may continue
59        /// at.
60        wake_at: OffsetDateTime,
61    },
62    /// The run needs human reconciliation. Carries the dangling write intent
63    /// so the caller can show it as evidence.
64    Reconcile(PendingCall),
65    /// The run already finished with this output.
66    Completed(serde_json::Value),
67    /// The run already failed with this error.
68    Failed(String),
69    /// The run was abandoned by an operator. A terminal resting state, reported
70    /// and left alone exactly as completed or failed is, distinct from failure.
71    Abandoned {
72        /// The operator's optional note.
73        reason: Option<String>,
74        /// The write intent left unsettled when a needs-reconciliation run was
75        /// abandoned, when there was one.
76        unresolved_write: Option<UnresolvedWrite>,
77    },
78    /// The log is empty; there is no run to continue.
79    NotStarted,
80}
81
82/// Maps a derived [`RunState`] to its [`Disposition`].
83#[must_use]
84pub fn classify(state: &RunState) -> Disposition {
85    match &state.status {
86        RunStatus::Suspended { .. } => Disposition::Resume(ResumeKind::Suspension),
87        RunStatus::BudgetExceeded { .. } => Disposition::Resume(ResumeKind::Budget),
88        RunStatus::Running | RunStatus::AwaitingModel | RunStatus::AwaitingTool => {
89            Disposition::Recover
90        }
91        // A sleeping run continues by being driven with no input, which is
92        // mechanically a recovery, so waking still needs no verb: both wakers
93        // (`salvor wake`, the server's sweeper) re-drive a due run through the
94        // ordinary path. What this arm will not do is answer for a caller that
95        // is early. Driving early was always harmless (`RunCtx::await_wake`
96        // reads the clock, records nothing, and leaves the run asleep) but it
97        // was also silent, and a person who typed `salvor resume` deserves to
98        // be told the run is on a timer and how long is left rather than to
99        // watch a no-op. So the deadline travels out of here and the caller
100        // decides.
101        RunStatus::Sleeping { wake_at } => Disposition::Sleeping { wake_at: *wake_at },
102        RunStatus::NeedsReconciliation => {
103            // A needs-reconciliation state always carries the pending write
104            // intent whose completion is missing; if it somehow did not, there
105            // is still nothing to drive, so recovery would refuse it too.
106            match &state.pending_call {
107                Some(pending @ PendingCall::Tool { .. }) => Disposition::Reconcile(pending.clone()),
108                _ => Disposition::Recover,
109            }
110        }
111        RunStatus::Completed { output } => Disposition::Completed(output.clone()),
112        RunStatus::Failed { error } => Disposition::Failed(error.clone()),
113        RunStatus::Abandoned {
114            reason,
115            unresolved_write,
116        } => Disposition::Abandoned {
117            reason: reason.clone(),
118            unresolved_write: unresolved_write.clone(),
119        },
120        RunStatus::NotStarted => Disposition::NotStarted,
121    }
122}