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}