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::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#[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 pub launch_failed: bool,
29 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#[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 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
92pub 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 None => Some(WaitDecision::simple(WaitOutcome::Finished, None)),
216 }
217 }
218 }
219}
220
221