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}