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// ---------------------------------------------------------------------------