Skip to main content

mj_controller/server/api/
wait_policy.rs

1use super::*;
2
3/// Classify a harness stop reason.
4///
5/// Stop reasons are free text the harness chooses, so the comparison is
6/// case-insensitive and tolerates both `end_turn` and `endTurn`. Anything
7/// unrecognized is an error carrying the raw reason, because silently calling
8/// an unknown ending "finished" would tell the caller its work succeeded when
9/// nobody knows that it did.
10pub fn map_stop_reason(stop_reason: &str) -> (WaitOutcome, Option<String>) {
11    use mj_core::state::{PromptCompletion, classify_prompt_completion};
12
13    match classify_prompt_completion(stop_reason) {
14        PromptCompletion::Finished => (WaitOutcome::Finished, None),
15        PromptCompletion::Cancelled => (WaitOutcome::Cancelled, None),
16        PromptCompletion::QuotaLimit => (WaitOutcome::QuotaLimit, None),
17        PromptCompletion::Error => (WaitOutcome::Error, Some(stop_reason.to_owned())),
18    }
19}
20
21/// Everything one pass of the wait loop knows about a session.
22#[derive(Debug, Clone, Default, PartialEq)]
23pub struct WaitObservation {
24    pub background_work: Option<ApiBackgroundWork>,
25    pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
26    pub lifecycle: Option<ViewerLifecycleCategory>,
27    /// A recorded launch failure names this session.
28    pub launch_failed: bool,
29    /// Why the launch failed, when a reason was recorded.
30    pub launch_error: Option<String>,
31    pub execution: MaterializedExecutionState,
32    pub active_turn: Option<MaterializedTurn>,
33    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
34    pub queued: usize,
35    pub capacity_retry: Option<CapacityRetry>,
36    pub start_status: Option<StartStatus>,
37}
38
39/// What one pass of the wait loop concluded, before the turn summary is read.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct WaitDecision {
42    pub outcome: WaitOutcome,
43    pub stop_reason: Option<String>,
44    pub message: Option<String>,
45    pub turn_id: Option<u64>,
46    /// Where the finished turn began, so its summary can be read.
47    pub turn_start_position: Option<u64>,
48}
49
50impl WaitDecision {
51    pub(super) fn simple(outcome: WaitOutcome, message: Option<String>) -> Self {
52        Self {
53            outcome,
54            stop_reason: None,
55            message,
56            turn_id: None,
57            turn_start_position: None,
58        }
59    }
60
61    pub(super) fn from_outcome(outcome: &MaterializedTurnOutcome) -> Self {
62        let (kind, stop_reason, message) = match &outcome.outcome {
63            TurnOutcomeKind::Completed { stop_reason } => {
64                let (kind, message) = map_stop_reason(stop_reason);
65                (
66                    kind,
67                    Some(stop_reason.clone()),
68                    outcome
69                        .diagnostic
70                        .as_ref()
71                        .map(|d| d.message.clone())
72                        .or(message),
73                )
74            }
75            TurnOutcomeKind::Rejected { message } => {
76                (WaitOutcome::Error, None, Some(message.clone()))
77            }
78            TurnOutcomeKind::Interrupted { message } => {
79                (WaitOutcome::Error, None, Some(message.clone()))
80            }
81        };
82        Self {
83            outcome: kind,
84            stop_reason,
85            message,
86            turn_id: outcome.accepted_ordinal,
87            turn_start_position: outcome.turn_start_position,
88        }
89    }
90}
91
92/// Decide whether this observation ends the wait.
93///
94/// A wait answers for one turn, so only the turn's own fate ends it. In
95/// particular a session that is carrying an error from some earlier, unrelated
96/// action is not a reason to fail the turn the caller asked about: the session
97/// error badge has no expiry, and reporting it here made every later wait on
98/// that session return `error` while the turn ran on perfectly well.
99///
100/// The rules run in order, and the order is the point:
101///
102/// 1. A stopped or stopping session ends the wait as `stopped`, superseding
103///    any initialization result that raced with the close request.
104/// 2. A launch failure or failed initialization is reported before a turn; a durable
105///    failed lifecycle ends it as `error` even after a daemon restart.
106/// 3. Otherwise the wait has a target turn: the caller's explicit `turn_id`,
107///    else the turn a create-with-prompt call submitted, else "the newest
108///    one", which additionally requires the session to be idle with an empty
109///    queue — with queued prompts, "idle" alone would return an earlier
110///    prompt's outcome.
111/// 4. A capacity outcome with a retry armed is not an ending: the worker will
112///    submit the retry itself, so the wait keeps waiting.
113///
114/// A turn that really did fail still reports `error`: a rejected or interrupted
115/// turn, and an unrecognized stop reason, all come back through the turn record
116/// in rule 3.
117pub fn resolve_wait(observation: &WaitObservation, request: &WaitRequest) -> Option<WaitDecision> {
118    let stopping = matches!(
119        observation.lifecycle,
120        Some(ViewerLifecycleCategory::Stopped | ViewerLifecycleCategory::Stopping)
121    ) || matches!(
122        observation.execution,
123        MaterializedExecutionState::Closing | MaterializedExecutionState::Closed
124    );
125    if stopping {
126        return Some(WaitDecision::simple(
127            WaitOutcome::Stopped,
128            Some("the session is stopped or stopping".to_owned()),
129        ));
130    }
131    if observation.launch_failed {
132        return Some(WaitDecision::simple(
133            WaitOutcome::Error,
134            Some(
135                observation
136                    .launch_error
137                    .clone()
138                    .unwrap_or_else(|| "the session failed to launch".to_owned()),
139            ),
140        ));
141    }
142    if let Some(StartStatus::Failed { message }) = &observation.start_status {
143        return Some(WaitDecision::simple(
144            WaitOutcome::Error,
145            Some(message.clone()),
146        ));
147    }
148    if observation.lifecycle == Some(ViewerLifecycleCategory::Failed) {
149        return Some(WaitDecision::simple(
150            WaitOutcome::Error,
151            Some("the session is in a failed state".to_owned()),
152        ));
153    }
154    let retry_pending = |outcome: &MaterializedTurnOutcome| {
155        observation.capacity_retry.is_some()
156            && matches!(
157                &outcome.outcome,
158                TurnOutcomeKind::Completed { stop_reason } if is_capacity_stop_reason(stop_reason)
159            )
160    };
161    let target = request.turn_id.or(match &observation.start_status {
162        Some(StartStatus::Submitted { turn_id }) => Some(*turn_id),
163        _ => None,
164    });
165    let target_finished = target.is_some_and(|target| {
166        observation
167            .last_turn_outcome
168            .as_ref()
169            .is_some_and(|outcome| {
170                outcome
171                    .accepted_ordinal
172                    .is_some_and(|ordinal| ordinal >= target)
173                    && !retry_pending(outcome)
174            })
175    });
176    if request.return_on_input && !target_finished && !observation.pending_elicitations.is_empty() {
177        return Some(WaitDecision {
178            outcome: WaitOutcome::InputRequired,
179            stop_reason: None,
180            message: Some("the harness needs a response to a structured input request".into()),
181            turn_id: observation
182                .active_turn
183                .as_ref()
184                .and_then(|turn| turn.accepted_ordinal),
185            turn_start_position: None,
186        });
187    }
188    match target {
189        Some(target) => {
190            let outcome = observation.last_turn_outcome.as_ref()?;
191            if outcome
192                .accepted_ordinal
193                .is_none_or(|ordinal| ordinal < target)
194            {
195                return None;
196            }
197            if retry_pending(outcome) {
198                return None;
199            }
200            Some(WaitDecision::from_outcome(outcome))
201        }
202        None => {
203            if observation.execution != MaterializedExecutionState::Idle
204                || observation.active_turn.is_some()
205                || observation.queued > 0
206            {
207                return None;
208            }
209            match observation.last_turn_outcome.as_ref() {
210                Some(outcome) if retry_pending(outcome) => None,
211                Some(outcome) => Some(WaitDecision::from_outcome(outcome)),
212                // Idle with nothing queued and nothing ever finished: there is
213                // no turn to wait for, so say so immediately rather than block
214                // for the full timeout.
215                None => Some(WaitDecision::simple(WaitOutcome::Finished, None)),
216            }
217        }
218    }
219}
220
221// ---------------------------------------------------------------------------
222// Router
223// ---------------------------------------------------------------------------