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 resume operation owns this session now. Its durable record still says
28    /// stopped — it stays stopped until the archive has been verified — so
29    /// without this a wait would answer `stopped` for a session that is on its
30    /// way up.
31    pub resuming: bool,
32    /// A close owns this session now. Like `resuming`, this ends nothing: the
33    /// wait follows the close until it finishes.
34    pub closing: bool,
35    /// The reason a close recorded on a session that is alive again, which is
36    /// what a close that failed leaves behind. It is published only until the
37    /// next action or transition for the session succeeds, so it always refers
38    /// to a close nobody has recovered from.
39    pub close_failure: Option<String>,
40    /// A recorded launch failure names this session.
41    pub launch_failed: bool,
42    /// Why the launch failed, when a reason was recorded.
43    pub launch_error: Option<String>,
44    pub execution: MaterializedExecutionState,
45    pub active_turn: Option<MaterializedTurn>,
46    pub last_turn_outcome: Option<MaterializedTurnOutcome>,
47    pub queued: usize,
48    pub capacity_retry: Option<CapacityRetry>,
49    pub start_status: Option<StartStatus>,
50}
51
52/// The transcript positions one finished turn covers.
53///
54/// A turn is a span, not a starting point. The session keeps recording after a
55/// turn ends — a harness resume notice arrives as an agent message of its own —
56/// and only what falls inside the span is that turn's work.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct TurnSpan {
59    pub start_position: u64,
60    pub completed_position: u64,
61}
62
63/// What one pass of the wait loop concluded, before the turn summary is read.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct WaitDecision {
66    pub outcome: WaitOutcome,
67    pub stop_reason: Option<String>,
68    pub message: Option<String>,
69    pub turn_id: Option<u64>,
70    /// Which transcript positions the finished turn covers, so its summary can
71    /// be read.
72    pub turn: Option<TurnSpan>,
73}
74
75impl WaitDecision {
76    pub(super) fn simple(outcome: WaitOutcome, message: Option<String>) -> Self {
77        Self {
78            outcome,
79            stop_reason: None,
80            message,
81            turn_id: None,
82            turn: None,
83        }
84    }
85
86    pub(super) fn from_outcome(outcome: &MaterializedTurnOutcome) -> Self {
87        let (kind, stop_reason, message) = match &outcome.outcome {
88            TurnOutcomeKind::Completed { stop_reason } => {
89                let (kind, message) = map_stop_reason(stop_reason);
90                (
91                    kind,
92                    Some(stop_reason.clone()),
93                    outcome
94                        .diagnostic
95                        .as_ref()
96                        .map(|d| d.message.clone())
97                        .or(message),
98                )
99            }
100            TurnOutcomeKind::Rejected { message } => {
101                (WaitOutcome::Error, None, Some(message.clone()))
102            }
103            TurnOutcomeKind::Interrupted { message } => {
104                (WaitOutcome::Error, None, Some(message.clone()))
105            }
106        };
107        Self {
108            outcome: kind,
109            stop_reason,
110            message,
111            turn_id: outcome.accepted_ordinal,
112            turn: outcome.turn_start_position.map(|start_position| TurnSpan {
113                start_position,
114                completed_position: outcome.completed_ordinal,
115            }),
116        }
117    }
118}
119
120/// Decide whether this observation ends the wait.
121///
122/// A wait answers for one turn, so only the turn's own fate ends it. In
123/// particular a session that is carrying an error from some earlier, unrelated
124/// action is not a reason to fail the turn the caller asked about: the session
125/// error badge has no expiry, and reporting it here made every later wait on
126/// that session return `error` while the turn ran on perfectly well.
127///
128/// The rules run in order, and the order is the point:
129///
130/// 0. A resume running for this session ends nothing: it is a session coming
131///    up, and its durable record says stopped until the archive is verified.
132///    A close running for it ends nothing either, for the same reason in
133///    reverse: the wait follows it and reports how it ended. A close that
134///    left the session alive failed, and this is the only place left to say
135///    so, because the request that asked for it was answered when it was
136///    admitted. That reason outlives the wait that started it, on purpose: a
137///    close can fail before the next command has even connected, and it is
138///    cleared as soon as anything for the session succeeds.
139/// 1. A stopped or stopping session ends the wait as `stopped`, superseding
140///    any initialization result that raced with the close request.
141/// 2. A launch failure or failed initialization is reported before a turn; a durable
142///    failed lifecycle ends it as `error` even after a daemon restart.
143/// 3. Otherwise the wait has a target turn: the caller's explicit `turn_id`,
144///    else the turn a create-with-prompt call submitted, else "the newest
145///    one", which additionally requires the session to be idle with an empty
146///    queue — with queued prompts, "idle" alone would return an earlier
147///    prompt's outcome.
148/// 4. A capacity outcome with a retry armed is not an ending: the worker will
149///    submit the retry itself, so the wait keeps waiting.
150///
151/// A turn that really did fail still reports `error`: a rejected or interrupted
152/// turn, and an unrecognized stop reason, all come back through the turn record
153/// in rule 3.
154pub fn resolve_wait(observation: &WaitObservation, request: &WaitRequest) -> Option<WaitDecision> {
155    let stopping = matches!(
156        observation.lifecycle,
157        Some(ViewerLifecycleCategory::Stopped | ViewerLifecycleCategory::Stopping)
158    ) || matches!(
159        observation.execution,
160        MaterializedExecutionState::Closing | MaterializedExecutionState::Closed
161    );
162    // A resume owns the session: nothing about it has settled yet, and its
163    // durable record still says stopped. The wait keeps waiting; its own
164    // deadline still bounds it.
165    if observation.resuming {
166        return None;
167    }
168    if let Some(reason) = &observation.close_failure {
169        return Some(WaitDecision::simple(
170            WaitOutcome::Error,
171            Some(reason.clone()),
172        ));
173    }
174    // The close owns the session; its own deadline still bounds this wait.
175    if observation.closing {
176        return None;
177    }
178    if stopping {
179        return Some(WaitDecision::simple(
180            WaitOutcome::Stopped,
181            // A resume that failed rolled the record back to stopped and left
182            // its reason there. Reporting it is the difference between "the
183            // session is stopped" and knowing why it did not come up.
184            Some(
185                observation
186                    .launch_error
187                    .clone()
188                    .unwrap_or_else(|| "the session is stopped or stopping".to_owned()),
189            ),
190        ));
191    }
192    if observation.launch_failed {
193        return Some(WaitDecision::simple(
194            WaitOutcome::Error,
195            Some(
196                observation
197                    .launch_error
198                    .clone()
199                    .unwrap_or_else(|| "the session failed to launch".to_owned()),
200            ),
201        ));
202    }
203    if let Some(StartStatus::Failed { message }) = &observation.start_status {
204        return Some(WaitDecision::simple(
205            WaitOutcome::Error,
206            Some(message.clone()),
207        ));
208    }
209    if observation.lifecycle == Some(ViewerLifecycleCategory::Failed) {
210        return Some(WaitDecision::simple(
211            WaitOutcome::Error,
212            // A close that left the session dead recorded why; saying only
213            // that it failed would throw that away.
214            Some(
215                observation
216                    .launch_error
217                    .clone()
218                    .unwrap_or_else(|| "the session is in a failed state".to_owned()),
219            ),
220        ));
221    }
222    let retry_pending = |outcome: &MaterializedTurnOutcome| {
223        observation.capacity_retry.is_some()
224            && matches!(
225                &outcome.outcome,
226                TurnOutcomeKind::Completed { stop_reason } if is_capacity_stop_reason(stop_reason)
227            )
228    };
229    let target = request.turn_id.or(match &observation.start_status {
230        Some(StartStatus::Submitted { turn_id }) => Some(*turn_id),
231        _ => None,
232    });
233    let target_finished = target.is_some_and(|target| {
234        observation
235            .last_turn_outcome
236            .as_ref()
237            .is_some_and(|outcome| {
238                outcome
239                    .accepted_ordinal
240                    .is_some_and(|ordinal| ordinal >= target)
241                    && !retry_pending(outcome)
242            })
243    });
244    if request.return_on_input && !target_finished && !observation.pending_elicitations.is_empty() {
245        return Some(WaitDecision {
246            outcome: WaitOutcome::InputRequired,
247            stop_reason: None,
248            message: Some("the harness needs a response to a structured input request".into()),
249            turn_id: observation
250                .active_turn
251                .as_ref()
252                .and_then(|turn| turn.accepted_ordinal),
253            turn: None,
254        });
255    }
256    match target {
257        Some(target) => {
258            let outcome = observation.last_turn_outcome.as_ref()?;
259            if outcome
260                .accepted_ordinal
261                .is_none_or(|ordinal| ordinal < target)
262            {
263                return None;
264            }
265            if retry_pending(outcome) {
266                return None;
267            }
268            Some(WaitDecision::from_outcome(outcome))
269        }
270        None => {
271            if observation.execution != MaterializedExecutionState::Idle
272                || observation.active_turn.is_some()
273                || observation.queued > 0
274            {
275                return None;
276            }
277            match observation.last_turn_outcome.as_ref() {
278                Some(outcome) if retry_pending(outcome) => None,
279                Some(outcome) => Some(WaitDecision::from_outcome(outcome)),
280                // Idle with nothing queued and nothing ever finished: there is
281                // no turn to wait for, so say so immediately rather than block
282                // for the full timeout.
283                None => Some(WaitDecision::simple(WaitOutcome::Finished, None)),
284            }
285        }
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Router
291// ---------------------------------------------------------------------------