mj_controller/server/api/
wait_policy.rs1use super::*;
2
3pub 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::InputRequired => (WaitOutcome::InputRequired, None),
15 PromptCompletion::Finished => (WaitOutcome::Finished, None),
16 PromptCompletion::Cancelled => (WaitOutcome::Cancelled, None),
17 PromptCompletion::QuotaLimit => (WaitOutcome::QuotaLimit, None),
18 PromptCompletion::Error => (WaitOutcome::Error, Some(stop_reason.to_owned())),
19 }
20}
21
22#[derive(Debug, Clone, Default, PartialEq)]
24pub struct WaitObservation {
25 pub checking_continuation: bool,
26 pub background_work: Option<ApiBackgroundWork>,
27 pub pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
28 pub lifecycle: Option<ViewerLifecycleCategory>,
29 pub resuming: bool,
34 pub closing: bool,
37 pub close_failure: Option<String>,
42 pub launch_failed: bool,
44 pub launch_error: Option<String>,
46 pub execution: MaterializedExecutionState,
47 pub active_turn: Option<MaterializedTurn>,
48 pub last_turn_outcome: Option<MaterializedTurnOutcome>,
49 pub queued: usize,
50 pub capacity_retry: Option<CapacityRetry>,
51 pub quota_recovery: Option<mj_core::continuation::QuotaRecovery>,
52 pub start_status: Option<StartStatus>,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct TurnSpan {
62 pub start_position: u64,
63 pub completed_position: u64,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct WaitDecision {
69 pub outcome: WaitOutcome,
70 pub stop_reason: Option<String>,
71 pub message: Option<String>,
72 pub turn_id: Option<u64>,
73 pub turn: Option<TurnSpan>,
76}
77
78impl WaitDecision {
79 pub(super) fn simple(outcome: WaitOutcome, message: Option<String>) -> Self {
80 Self {
81 outcome,
82 stop_reason: None,
83 message,
84 turn_id: None,
85 turn: None,
86 }
87 }
88
89 pub(super) fn from_outcome(outcome: &MaterializedTurnOutcome) -> Self {
90 let (kind, stop_reason, message) = match &outcome.outcome {
91 TurnOutcomeKind::Completed { stop_reason } => {
92 let (kind, message) = map_stop_reason(stop_reason);
93 (
94 kind,
95 Some(stop_reason.clone()),
96 outcome
97 .diagnostic
98 .as_ref()
99 .map(|d| d.message.clone())
100 .or(message),
101 )
102 }
103 TurnOutcomeKind::Rejected { message } => {
104 (WaitOutcome::Error, None, Some(message.clone()))
105 }
106 TurnOutcomeKind::Interrupted { message } => {
107 (WaitOutcome::Error, None, Some(message.clone()))
108 }
109 };
110 Self {
111 outcome: kind,
112 stop_reason,
113 message,
114 turn_id: outcome.accepted_ordinal,
115 turn: outcome.turn_start_position.map(|start_position| TurnSpan {
116 start_position,
117 completed_position: outcome.completed_ordinal,
118 }),
119 }
120 }
121}
122
123pub fn resolve_wait(observation: &WaitObservation, request: &WaitRequest) -> Option<WaitDecision> {
158 let stopping = matches!(
159 observation.lifecycle,
160 Some(ViewerLifecycleCategory::Suspended | ViewerLifecycleCategory::Suspending)
161 ) || matches!(
162 observation.execution,
163 MaterializedExecutionState::Closing | MaterializedExecutionState::Closed
164 );
165 if observation.resuming {
169 return None;
170 }
171 if let Some(reason) = &observation.close_failure {
172 return Some(WaitDecision::simple(
173 WaitOutcome::Error,
174 Some(reason.clone()),
175 ));
176 }
177 if observation.closing {
179 return None;
180 }
181 if stopping {
182 return Some(WaitDecision::simple(
183 WaitOutcome::Stopped,
184 Some(
188 observation
189 .launch_error
190 .clone()
191 .unwrap_or_else(|| "the session is stopped or stopping".to_owned()),
192 ),
193 ));
194 }
195 if observation.launch_failed {
196 return Some(WaitDecision::simple(
197 WaitOutcome::Error,
198 Some(
199 observation
200 .launch_error
201 .clone()
202 .unwrap_or_else(|| "the session failed to launch".to_owned()),
203 ),
204 ));
205 }
206 if let Some(StartStatus::Failed { message }) = &observation.start_status {
207 return Some(WaitDecision::simple(
208 WaitOutcome::Error,
209 Some(message.clone()),
210 ));
211 }
212 if observation.lifecycle == Some(ViewerLifecycleCategory::Failed) {
213 return Some(WaitDecision::simple(
214 WaitOutcome::Error,
215 Some(
218 observation
219 .launch_error
220 .clone()
221 .unwrap_or_else(|| "the session is in a failed state".to_owned()),
222 ),
223 ));
224 }
225 let retry_pending = |outcome: &MaterializedTurnOutcome| {
226 observation.quota_recovery.as_ref().is_some_and(|r| {
227 r.retry_at_ms.is_some() && r.completed_command_id == outcome.command_id
228 }) || observation.capacity_retry.is_some()
229 && matches!(
230 &outcome.outcome,
231 TurnOutcomeKind::Completed { stop_reason } if is_capacity_stop_reason(stop_reason)
232 )
233 };
234 if let Some(recovery) = &observation.quota_recovery
235 && recovery.retry_at_ms.is_none()
236 && observation
237 .last_turn_outcome
238 .as_ref()
239 .is_some_and(|t| t.command_id == recovery.completed_command_id)
240 && request.turn_id.is_none_or(|target| {
241 observation
242 .last_turn_outcome
243 .as_ref()
244 .and_then(|t| t.accepted_ordinal)
245 .is_some_and(|a| a >= target)
246 })
247 {
248 return Some(WaitDecision::simple(
249 WaitOutcome::QuotaLimit,
250 Some(recovery.notice.clone()),
251 ));
252 }
253 let target = request.turn_id.or(match &observation.start_status {
254 Some(StartStatus::Submitted { turn_id }) => Some(*turn_id),
255 _ => None,
256 });
257 let target_finished = target.is_some_and(|target| {
258 observation
259 .last_turn_outcome
260 .as_ref()
261 .is_some_and(|outcome| {
262 outcome
263 .accepted_ordinal
264 .is_some_and(|ordinal| ordinal >= target)
265 && !retry_pending(outcome)
266 })
267 });
268 if request.return_on_input && !target_finished && !observation.pending_elicitations.is_empty() {
269 return Some(WaitDecision {
270 outcome: WaitOutcome::InputRequired,
271 stop_reason: None,
272 message: Some("the harness needs a response to a structured input request".into()),
273 turn_id: observation
274 .active_turn
275 .as_ref()
276 .and_then(|turn| turn.accepted_ordinal),
277 turn: None,
278 });
279 }
280 match target {
281 Some(target) => {
282 let outcome = observation.last_turn_outcome.as_ref()?;
283 if outcome
284 .accepted_ordinal
285 .is_none_or(|ordinal| ordinal < target)
286 {
287 return None;
288 }
289 if retry_pending(outcome) {
290 return None;
291 }
292 Some(WaitDecision::from_outcome(outcome))
293 }
294 None => {
295 if observation.checking_continuation
296 || observation.execution != MaterializedExecutionState::Idle
297 || observation.active_turn.is_some()
298 || observation.queued > 0
299 {
300 return None;
301 }
302 match observation.last_turn_outcome.as_ref() {
303 Some(outcome) if retry_pending(outcome) => None,
304 Some(outcome) => Some(WaitDecision::from_outcome(outcome)),
305 None => Some(WaitDecision::simple(WaitOutcome::Finished, None)),
309 }
310 }
311 }
312}
313
314#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn awaiting_input_is_a_successful_wait_outcome() {
324 assert_eq!(
325 map_stop_reason(mj_core::acp::AWAITING_INPUT_STOP_REASON),
326 (WaitOutcome::InputRequired, None)
327 );
328 }
329}