Skip to main content

mj_controller/server/api/
turns.rs

1use super::*;
2
3#[derive(Default, Deserialize)]
4pub(super) struct NativeHistoryQuery {
5    before_position: Option<u64>,
6    before_id: Option<String>,
7}
8
9pub(super) async fn transcript_history(
10    State(state): State<ServerState>,
11    Path(session_id): Path<String>,
12    Query(query): Query<NativeHistoryQuery>,
13) -> Result<Json<serde_json::Value>, ApiFailure> {
14    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
15    let before = match (query.before_position, query.before_id) {
16        (Some(position), Some(stable_id)) => Some(mj_core::storage::TranscriptCursor {
17            position,
18            stable_id,
19        }),
20        (None, None) => None,
21        _ => {
22            return Err(ApiFailure::bad_request(
23                "both before_position and before_id are required",
24            ));
25        }
26    };
27    let page = backend(&state)?
28        .transcript_history(session_id, before)
29        .await?;
30    let response = tokio::task::spawn_blocking(move || {
31        let entries = mj_client::transcript::history_entries(&page);
32        serde_json::json!({"items": entries, "before": page.before, "frontier": page.frontier})
33    })
34    .await
35    .map_err(|error| anyhow::anyhow!("history rendering task failed: {error}"))?;
36    Ok(Json(response))
37}
38
39pub(super) async fn native_agent_history(
40    State(state): State<ServerState>,
41    Path((owner, child)): Path<(String, String)>,
42    Query(query): Query<NativeHistoryQuery>,
43) -> Result<Json<serde_json::Value>, ApiFailure> {
44    require_session_record(&state.snapshot_rx.borrow(), &owner)?;
45    let before = query.before_position.zip(query.before_id);
46    let page = backend(&state)?
47        .native_agent_history(owner, child, before)
48        .await?;
49    let items = page
50        .items
51        .iter()
52        .map(|item| {
53            serde_json::json!({
54                "stable_id": item.stable_id,
55                "position": item.position,
56                "role": mj_core::transcript::transcript_item_role(&item.body),
57                "text": mj_transcript::transcript::transcript_item_text(item),
58            })
59        })
60        .collect::<Vec<_>>();
61    Ok(Json(
62        serde_json::json!({"generation": page.generation_ordinal, "items": items, "has_more": page.has_more}),
63    ))
64}
65
66pub(super) async fn prompt(
67    State(state): State<ServerState>,
68    Path(session_id): Path<String>,
69    Json(request): Json<PromptRequest>,
70) -> Result<(StatusCode, Json<PromptResponse>), ApiFailure> {
71    let backend = backend(&state)?.clone();
72    let action = ControllerAction::Prompt {
73        command_id: None,
74        session_id: session_id.clone(),
75        text: request.text.clone(),
76        images: Vec::new(),
77    };
78    // A session that is still coming up takes its prompt once its worker
79    // attaches, rather than refusing it. A caller that just created the
80    // session has nothing better to do than retry, and every caller would
81    // otherwise need its own retry loop around a refusal it cannot tell apart
82    // from a session that will never take a prompt.
83    let mut snapshot_rx = state.snapshot_rx.clone();
84    let deadline = tokio::time::Instant::now() + PROMPT_READINESS_WAIT;
85    let mut waiting = false;
86    // While the prompt is held it has no turn, so an interrupt withdraws it
87    // instead; see `interrupt_turn`.
88    let mut hold: Option<HeldPrompt<'_>> = None;
89    loop {
90        {
91            let snapshot = snapshot_rx.borrow_and_update();
92            validate_action(&action, &snapshot)?;
93            let session = require_session_record(&snapshot, &session_id)?;
94            if session.capabilities.prompt {
95                break;
96            }
97            // Between the worker's handshake and its first report the record
98            // already says running while the session still has nothing to
99            // take a prompt with, so once a start has been seen only a
100            // failure or a stop ends the wait early.
101            waiting = if waiting {
102                still_live(session)
103            } else {
104                is_coming_up(session)
105            };
106            if !waiting {
107                return Err(ApiFailure::conflict(prompt_refusal(session)));
108            }
109        }
110        let held = hold.get_or_insert_with(|| state.held_prompts.hold(&session_id));
111        tokio::select! {
112            changed = tokio::time::timeout_at(deadline, snapshot_rx.changed()) => match changed {
113                Ok(Ok(())) => {}
114                Ok(Err(_)) => {
115                    return Err(ApiFailure::unavailable("the daemon is shutting down"));
116                }
117                Err(_) => {
118                    return Err(ApiFailure::conflict(format!(
119                        "this session is still starting after {} seconds; try the prompt again once `mj sessions` shows it running",
120                        PROMPT_READINESS_WAIT.as_secs()
121                    )));
122                }
123            },
124            () = held.withdrawn() => return Err(withdrawn_prompt()),
125        }
126    }
127    // Taking the hold back and an interrupt withdrawing it are one decision,
128    // so a prompt is either submitted or withdrawn, never both.
129    if hold.is_some_and(|held| !held.release()) {
130        return Err(withdrawn_prompt());
131    }
132    let turn_id = backend.prompt(session_id, request.text).await?;
133    Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
134}
135
136fn withdrawn_prompt() -> ApiFailure {
137    ApiFailure::conflict(
138        "this prompt was withdrawn: its turn was interrupted before the session could take it",
139    )
140}
141
142/// Prompts the API is holding for sessions that are still starting.
143///
144/// A held prompt has no turn yet, so interrupting its session withdraws it
145/// rather than letting it become a turn nobody wants once the worker
146/// attaches (R2-1).
147#[derive(Default)]
148pub(crate) struct HeldPrompts {
149    next_id: std::sync::atomic::AtomicU64,
150    held: std::sync::Mutex<
151        std::collections::HashMap<
152            String,
153            std::collections::BTreeMap<u64, tokio::sync::oneshot::Sender<()>>,
154        >,
155    >,
156}
157
158impl HeldPrompts {
159    fn hold(&self, session_id: &str) -> HeldPrompt<'_> {
160        let id = self
161            .next_id
162            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
163        let (withdraw, withdrawn) = tokio::sync::oneshot::channel();
164        self.held
165            .lock()
166            .unwrap_or_else(std::sync::PoisonError::into_inner)
167            .entry(session_id.to_owned())
168            .or_default()
169            .insert(id, withdraw);
170        HeldPrompt {
171            prompts: self,
172            session_id: session_id.to_owned(),
173            id,
174            withdrawn,
175            released: false,
176        }
177    }
178
179    /// Withdraw every prompt held for one session, answering how many there
180    /// were.
181    fn withdraw(&self, session_id: &str) -> usize {
182        let withdrawn = self
183            .held
184            .lock()
185            .unwrap_or_else(std::sync::PoisonError::into_inner)
186            .remove(session_id)
187            .unwrap_or_default();
188        let count = withdrawn.len();
189        for withdraw in withdrawn.into_values() {
190            // The holder may have given up on its own in the meantime; then
191            // there is nobody left to tell.
192            let _ = withdraw.send(());
193        }
194        count
195    }
196
197    /// Take one hold back, answering whether it was still held.
198    fn take(&self, session_id: &str, id: u64) -> bool {
199        let mut held = self
200            .held
201            .lock()
202            .unwrap_or_else(std::sync::PoisonError::into_inner);
203        let Some(prompts) = held.get_mut(session_id) else {
204            return false;
205        };
206        let taken = prompts.remove(&id).is_some();
207        if prompts.is_empty() {
208            held.remove(session_id);
209        }
210        taken
211    }
212}
213
214/// One prompt held for a starting session. Dropping it without releasing it,
215/// as every refusal does, gives the hold up.
216struct HeldPrompt<'a> {
217    prompts: &'a HeldPrompts,
218    session_id: String,
219    id: u64,
220    withdrawn: tokio::sync::oneshot::Receiver<()>,
221    released: bool,
222}
223
224impl HeldPrompt<'_> {
225    /// Resolves once an interrupt withdraws this prompt.
226    async fn withdrawn(&mut self) {
227        if (&mut self.withdrawn).await.is_err() {
228            // Only this hold's own release drops the sender without sending,
229            // and a released hold is never awaited again.
230            std::future::pending::<()>().await;
231        }
232    }
233
234    /// Take the prompt back to submit it: `false` when an interrupt withdrew
235    /// it first.
236    fn release(mut self) -> bool {
237        self.released = true;
238        self.prompts.take(&self.session_id, self.id)
239    }
240}
241
242impl Drop for HeldPrompt<'_> {
243    fn drop(&mut self) {
244        if !self.released {
245            self.prompts.take(&self.session_id, self.id);
246        }
247    }
248}
249
250/// How long a prompt waits for a session that is still starting. Bounded, so
251/// the request finishes with its own response, and shorter than the CLI's
252/// request timeout so the caller reads this answer rather than a timeout.
253const PROMPT_READINESS_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
254
255/// Why a session that is not coming up refuses a prompt. A failed session
256/// will not take one however long the caller waits, so the refusal says it
257/// failed, why, and what can be done with it instead (launch finding R5-8).
258/// Resume is named only for a session with a checkpoint, the one thing a
259/// resume restores from (launch finding R6-1).
260fn prompt_refusal(session: &ViewerSession) -> String {
261    if session.lifecycle != ViewerLifecycleCategory::Failed {
262        return "this session cannot take a prompt right now".to_owned();
263    }
264    let id = &session.id;
265    let ways_out = [
266        (session.has_checkpoint && session.capabilities.resume)
267            .then(|| format!("resume it with `mj resume --session {id}`")),
268        session
269            .capabilities
270            .destroy
271            .then(|| format!("remove it with `mj destroy --session {id}`")),
272    ]
273    .into_iter()
274    .flatten()
275    .collect::<Vec<_>>();
276    let mut refusal = if session.has_checkpoint {
277        "this session failed, so it cannot take a prompt"
278    } else {
279        "this session failed before it saved a checkpoint, so it cannot take a prompt or be resumed"
280    }
281    .to_owned();
282    if !ways_out.is_empty() {
283        refusal.push_str("; ");
284        refusal.push_str(&ways_out.join(" or "));
285    }
286    refusal.push('.');
287    if let Some(reason) = &session.launch_error {
288        refusal.push_str(" It failed with: ");
289        refusal.push_str(reason);
290    }
291    refusal
292}
293
294/// Whether a session is on its way to taking prompts: provisioning, or
295/// provisioned and waiting for its worker to attach.
296fn is_coming_up(session: &ViewerSession) -> bool {
297    use mj_core::state::SessionState;
298    still_live(session)
299        && (session.state == crate::server::LAUNCHING_STATE
300            || matches!(
301                SessionState::from_stored(&session.state),
302                Some(SessionState::Provisioning | SessionState::Disconnected)
303            ))
304}
305
306/// Whether a session is starting or running without a recorded failure.
307fn still_live(session: &ViewerSession) -> bool {
308    !session.has_error
309        && matches!(
310            session.lifecycle,
311            ViewerLifecycleCategory::Starting | ViewerLifecycleCategory::Live
312        )
313}
314
315/// Page through a session's transcript.
316///
317/// It reads the durable projection rather than the live actor, so it answers
318/// the same way while a session runs and long after it stopped.
319#[derive(Debug, Clone, Default, Serialize, Deserialize)]
320pub struct UsageQuery {
321    pub after_seq: Option<u64>,
322    pub limit: Option<usize>,
323}
324
325pub(super) async fn usage(
326    State(state): State<ServerState>,
327    Path(session_id): Path<String>,
328    Query(query): Query<UsageQuery>,
329) -> Result<Json<crate::database::UsagePage>, ApiFailure> {
330    let page = backend(&state)?
331        .usage(
332            session_id,
333            query.after_seq.unwrap_or(0),
334            query.limit.unwrap_or(200).clamp(1, 1000),
335        )
336        .await?
337        .ok_or_else(|| ApiFailure::not_found("no usage history is recorded for that session"))?;
338    Ok(Json(page))
339}
340
341pub(super) async fn transcript(
342    State(state): State<ServerState>,
343    Path(session_id): Path<String>,
344    Query(query): Query<TranscriptQuery>,
345) -> Result<Json<TranscriptResponse>, ApiFailure> {
346    let backend = backend(&state)?.clone();
347    let limit = query
348        .limit
349        .unwrap_or(DEFAULT_TRANSCRIPT_LIMIT)
350        .clamp(1, MAX_TRANSCRIPT_LIMIT);
351    let page = backend
352        .transcript(
353            session_id.clone(),
354            query.after_seq.unwrap_or(0),
355            limit,
356            query.role,
357        )
358        .await?
359        .ok_or_else(|| ApiFailure::not_found("no transcript is recorded for that session"))?;
360    Ok(Json(TranscriptResponse {
361        next_after_seq: page.next_after_seq,
362        session_id,
363        latest_seq: page.latest_seq,
364        execution: page.execution,
365        items: page
366            .items
367            .iter()
368            .map(|item| TranscriptItemView {
369                stable_id: item.stable_id.clone(),
370                position: item.position,
371                seq: item.seq(),
372                role: mj_core::transcript::transcript_item_role(&item.body).to_owned(),
373                text: mj_transcript::transcript::transcript_item_text(item),
374                created_at_ms: item.created_at_ms,
375                last_changed_at_ms: item.last_changed_at_ms,
376                body: item.body.clone(),
377            })
378            .collect(),
379    }))
380}
381
382pub(super) async fn suspend(
383    State(state): State<ServerState>,
384    Path(session_id): Path<String>,
385    request: Option<Json<SuspendRequest>>,
386) -> Result<StatusCode, ApiFailure> {
387    let (active_children, publication_state) = {
388        let snapshot = state.snapshot_rx.borrow();
389        let session = require_session_record(&snapshot, &session_id)?;
390        let active_children = session
391            .subagent_session_ids
392            .iter()
393            .filter(|id| {
394                snapshot
395                    .sessions
396                    .iter()
397                    .any(|child| child.id == id.as_str() && child.lifecycle.is_dashboard_visible())
398            })
399            .count();
400        (active_children, session.publication_state)
401    };
402    if active_children > 0
403        && !request
404            .as_ref()
405            .is_some_and(|r| r.acknowledge_active_subagents)
406    {
407        return Err(ApiFailure::conflict(format!(
408            "session has {active_children} sub-agent(s); retry with acknowledge_active_subagents=true to suspend children first"
409        )));
410    }
411    // The controller re-checks publication after its checkpoint; this is only
412    // an early refusal that avoids a needless checkpoint.
413    if publication_state.is_some()
414        && !request
415            .as_ref()
416            .is_some_and(|r| r.acknowledge_unpublished_work)
417    {
418        return Err(ApiFailure::conflict(
419            "publication status is unverified for this live clone; retry with acknowledge_unpublished_work=true to suspend it",
420        ));
421    }
422    backend(&state)?.cancel_start(session_id.clone()).await?;
423    send_action(
424        &state,
425        ControllerAction::Suspend {
426            session_id,
427            acknowledge_unpublished_work: request
428                .as_ref()
429                .is_some_and(|r| r.acknowledge_unpublished_work),
430        },
431    )
432    .await
433}
434
435#[derive(Debug, Default, serde::Deserialize)]
436#[serde(deny_unknown_fields)]
437pub(super) struct SuspendRequest {
438    #[serde(default)]
439    pub(super) acknowledge_active_subagents: bool,
440    #[serde(default)]
441    pub(super) acknowledge_unpublished_work: bool,
442}
443
444pub(super) async fn destroy(
445    State(state): State<ServerState>,
446    Path(session_id): Path<String>,
447    request: Option<Json<DestroyRequest>>,
448) -> Result<StatusCode, ApiFailure> {
449    backend(&state)?.cancel_start(session_id.clone()).await?;
450    let delete_branch = request.is_some_and(|r| r.delete_branch);
451    send_action(
452        &state,
453        ControllerAction::Destroy {
454            session_id,
455            delete_branch,
456        },
457    )
458    .await
459}
460
461#[derive(Debug, Default, serde::Deserialize)]
462#[serde(deny_unknown_fields)]
463pub(super) struct DestroyRequest {
464    #[serde(default)]
465    pub(super) delete_branch: bool,
466}
467
468pub(super) async fn interrupt_turn(
469    State(state): State<ServerState>,
470    Path(session_id): Path<String>,
471) -> Result<StatusCode, ApiFailure> {
472    // A prompt still held for a starting session has no turn to stop, so
473    // interrupting the session withdraws it. Only a session that also has a
474    // turn of its own goes on to the daemon.
475    if state.held_prompts.withdraw(&session_id) > 0 {
476        let has_turn = state
477            .snapshot_rx
478            .borrow()
479            .sessions
480            .iter()
481            .any(|session| session.id == session_id && session.capabilities.interrupt_turn);
482        if !has_turn {
483            return Ok(StatusCode::ACCEPTED);
484        }
485    }
486    send_action(&state, ControllerAction::InterruptTurn { session_id }).await
487}
488
489pub(super) async fn send_action(
490    state: &ServerState,
491    action: ControllerAction,
492) -> Result<StatusCode, ApiFailure> {
493    validate_action(&action, &state.snapshot_rx.borrow())?;
494    let (reply, outcome) = tokio::sync::oneshot::channel();
495    state
496        .action_tx
497        .send(ControllerRequest { action, reply })
498        .await
499        .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
500    let outcome = outcome
501        .await
502        .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
503    match outcome.rejection() {
504        Some(rejection) => Err(rejection.into()),
505        None => Ok(StatusCode::ACCEPTED),
506    }
507}