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: request.command_id.clone(),
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
133        .prompt_with_id(session_id, request.text, request.command_id)
134        .await?;
135    Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
136}
137
138fn withdrawn_prompt() -> ApiFailure {
139    ApiFailure::conflict(
140        "this prompt was withdrawn: its turn was interrupted before the session could take it",
141    )
142}
143
144/// Prompts the API is holding for sessions that are still starting.
145///
146/// A held prompt has no turn yet, so interrupting its session withdraws it
147/// rather than letting it become a turn nobody wants once the worker
148/// attaches (R2-1).
149#[derive(Default)]
150pub(crate) struct HeldPrompts {
151    next_id: std::sync::atomic::AtomicU64,
152    held: std::sync::Mutex<
153        std::collections::HashMap<
154            String,
155            std::collections::BTreeMap<u64, tokio::sync::oneshot::Sender<()>>,
156        >,
157    >,
158}
159
160impl HeldPrompts {
161    fn hold(&self, session_id: &str) -> HeldPrompt<'_> {
162        let id = self
163            .next_id
164            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
165        let (withdraw, withdrawn) = tokio::sync::oneshot::channel();
166        self.held
167            .lock()
168            .unwrap_or_else(std::sync::PoisonError::into_inner)
169            .entry(session_id.to_owned())
170            .or_default()
171            .insert(id, withdraw);
172        HeldPrompt {
173            prompts: self,
174            session_id: session_id.to_owned(),
175            id,
176            withdrawn,
177            released: false,
178        }
179    }
180
181    /// Withdraw every prompt held for one session, answering how many there
182    /// were.
183    fn withdraw(&self, session_id: &str) -> usize {
184        let withdrawn = self
185            .held
186            .lock()
187            .unwrap_or_else(std::sync::PoisonError::into_inner)
188            .remove(session_id)
189            .unwrap_or_default();
190        let count = withdrawn.len();
191        for withdraw in withdrawn.into_values() {
192            // The holder may have given up on its own in the meantime; then
193            // there is nobody left to tell.
194            let _ = withdraw.send(());
195        }
196        count
197    }
198
199    /// Take one hold back, answering whether it was still held.
200    fn take(&self, session_id: &str, id: u64) -> bool {
201        let mut held = self
202            .held
203            .lock()
204            .unwrap_or_else(std::sync::PoisonError::into_inner);
205        let Some(prompts) = held.get_mut(session_id) else {
206            return false;
207        };
208        let taken = prompts.remove(&id).is_some();
209        if prompts.is_empty() {
210            held.remove(session_id);
211        }
212        taken
213    }
214}
215
216/// One prompt held for a starting session. Dropping it without releasing it,
217/// as every refusal does, gives the hold up.
218struct HeldPrompt<'a> {
219    prompts: &'a HeldPrompts,
220    session_id: String,
221    id: u64,
222    withdrawn: tokio::sync::oneshot::Receiver<()>,
223    released: bool,
224}
225
226impl HeldPrompt<'_> {
227    /// Resolves once an interrupt withdraws this prompt.
228    async fn withdrawn(&mut self) {
229        if (&mut self.withdrawn).await.is_err() {
230            // Only this hold's own release drops the sender without sending,
231            // and a released hold is never awaited again.
232            std::future::pending::<()>().await;
233        }
234    }
235
236    /// Take the prompt back to submit it: `false` when an interrupt withdrew
237    /// it first.
238    fn release(mut self) -> bool {
239        self.released = true;
240        self.prompts.take(&self.session_id, self.id)
241    }
242}
243
244impl Drop for HeldPrompt<'_> {
245    fn drop(&mut self) {
246        if !self.released {
247            self.prompts.take(&self.session_id, self.id);
248        }
249    }
250}
251
252/// How long a prompt waits for a session that is still starting. Bounded, so
253/// the request finishes with its own response, and shorter than the CLI's
254/// request timeout so the caller reads this answer rather than a timeout.
255const PROMPT_READINESS_WAIT: std::time::Duration = std::time::Duration::from_secs(60);
256
257/// Why a session that is not coming up refuses a prompt. A failed session
258/// will not take one however long the caller waits, so the refusal says it
259/// failed, why, and what can be done with it instead (launch finding R5-8).
260/// Resume is named only for a session with a checkpoint, the one thing a
261/// resume restores from (launch finding R6-1).
262fn prompt_refusal(session: &ViewerSession) -> String {
263    if session.lifecycle != ViewerLifecycleCategory::Failed {
264        return "this session cannot take a prompt right now".to_owned();
265    }
266    let id = &session.id;
267    let ways_out = [
268        (session.has_checkpoint && session.capabilities.resume)
269            .then(|| format!("resume it with `mj resume --session {id}`")),
270        session
271            .capabilities
272            .destroy
273            .then(|| format!("remove it with `mj destroy --session {id}`")),
274    ]
275    .into_iter()
276    .flatten()
277    .collect::<Vec<_>>();
278    let mut refusal = if session.has_checkpoint {
279        "this session failed, so it cannot take a prompt"
280    } else {
281        "this session failed before it saved a checkpoint, so it cannot take a prompt or be resumed"
282    }
283    .to_owned();
284    if !ways_out.is_empty() {
285        refusal.push_str("; ");
286        refusal.push_str(&ways_out.join(" or "));
287    }
288    refusal.push('.');
289    if let Some(reason) = &session.launch_error {
290        refusal.push_str(" It failed with: ");
291        refusal.push_str(reason);
292    }
293    refusal
294}
295
296/// Whether a session is on its way to taking prompts: provisioning, or
297/// provisioned and waiting for its worker to attach.
298fn is_coming_up(session: &ViewerSession) -> bool {
299    use mj_core::state::SessionState;
300    still_live(session)
301        && (session.state == crate::server::LAUNCHING_STATE
302            || matches!(
303                SessionState::from_stored(&session.state),
304                Some(SessionState::Provisioning | SessionState::Disconnected)
305            ))
306}
307
308/// Whether a session is starting or running without a recorded failure.
309fn still_live(session: &ViewerSession) -> bool {
310    !session.has_error
311        && matches!(
312            session.lifecycle,
313            ViewerLifecycleCategory::Starting | ViewerLifecycleCategory::Live
314        )
315}
316
317/// Page through a session's transcript.
318///
319/// It reads the durable projection rather than the live actor, so it answers
320/// the same way while a session runs and long after it stopped.
321#[derive(Debug, Clone, Default, Serialize, Deserialize)]
322pub struct UsageQuery {
323    pub after_seq: Option<u64>,
324    pub limit: Option<usize>,
325}
326
327pub(super) async fn usage(
328    State(state): State<ServerState>,
329    Path(session_id): Path<String>,
330    Query(query): Query<UsageQuery>,
331) -> Result<Json<crate::database::UsagePage>, ApiFailure> {
332    let page = backend(&state)?
333        .usage(
334            session_id,
335            query.after_seq.unwrap_or(0),
336            query.limit.unwrap_or(200).clamp(1, 1000),
337        )
338        .await?
339        .ok_or_else(|| ApiFailure::not_found("no usage history is recorded for that session"))?;
340    Ok(Json(page))
341}
342
343pub(super) async fn usage_tree(
344    State(state): State<ServerState>,
345    Path(session_id): Path<String>,
346) -> Result<Json<crate::database::UsageTree>, ApiFailure> {
347    let tree = backend(&state)?
348        .usage_tree(session_id)
349        .await?
350        .ok_or_else(|| ApiFailure::not_found("no accounting identity exists for that session"))?;
351    Ok(Json(tree))
352}
353
354pub(super) async fn transcript(
355    State(state): State<ServerState>,
356    Path(session_id): Path<String>,
357    Query(query): Query<TranscriptQuery>,
358) -> Result<Json<TranscriptResponse>, ApiFailure> {
359    let backend = backend(&state)?.clone();
360    let limit = query
361        .limit
362        .unwrap_or(DEFAULT_TRANSCRIPT_LIMIT)
363        .clamp(1, MAX_TRANSCRIPT_LIMIT);
364    let roles = if query.finished_only {
365        if query
366            .role
367            .iter()
368            .any(|role| *role != mj_core::transcript::TranscriptRole::Agent)
369        {
370            return Err(ApiFailure::bad_request(
371                "finished_only can only be combined with role=agent",
372            ));
373        }
374        vec![mj_core::transcript::TranscriptRole::Agent]
375    } else {
376        query.role
377    };
378    let page = backend
379        .transcript(
380            session_id.clone(),
381            query.after_seq.unwrap_or(0),
382            limit,
383            roles,
384            query.finished_only,
385        )
386        .await?
387        .ok_or_else(|| ApiFailure::not_found("no transcript is recorded for that session"))?;
388    Ok(Json(TranscriptResponse {
389        next_after_seq: page.next_after_seq,
390        session_id,
391        latest_seq: page.latest_seq,
392        execution: page.execution,
393        items: page
394            .items
395            .iter()
396            .map(|item| TranscriptItemView {
397                stable_id: item.stable_id.clone(),
398                position: item.position,
399                seq: item.seq(),
400                role: mj_core::transcript::transcript_item_role(&item.body).to_owned(),
401                text: mj_transcript::transcript::transcript_item_text(item),
402                created_at_ms: item.created_at_ms,
403                last_changed_at_ms: item.last_changed_at_ms,
404                body: item.body.clone(),
405            })
406            .collect(),
407    }))
408}
409
410/// Suspend a session. Its active Mjolnir sub-agents are stopped without a
411/// checkpoint and the session is suspended alone; the answer warns when some
412/// of those sub-agents have not handed back their reports. Accepting the
413/// suspend is not finishing it.
414pub(super) async fn suspend(
415    State(state): State<ServerState>,
416    Path(session_id): Path<String>,
417    request: Option<Json<SuspendRequest>>,
418) -> Result<(StatusCode, Json<SuspendSessionResponse>), ApiFailure> {
419    let (active_children, publication_state) = {
420        let snapshot = state.snapshot_rx.borrow();
421        let session = require_session_record(&snapshot, &session_id)?;
422        let active_children = session
423            .subagent_session_ids
424            .iter()
425            .filter(|id| {
426                snapshot
427                    .sessions
428                    .iter()
429                    .any(|child| child.id == id.as_str() && child.lifecycle.is_dashboard_visible())
430            })
431            .cloned()
432            .collect::<Vec<_>>();
433        (active_children, session.publication_state)
434    };
435    // The controller re-checks publication after its checkpoint; this is only
436    // an early refusal that avoids a needless checkpoint.
437    if publication_state.is_some()
438        && !request
439            .as_ref()
440            .is_some_and(|r| r.acknowledge_unpublished_work)
441    {
442        return Err(ApiFailure::conflict(
443            "publication status is unverified for this live clone; retry with acknowledge_unpublished_work=true to suspend it",
444        ));
445    }
446    let backend = backend(&state)?.clone();
447    // Counted before the suspend is sent, which is what stops the children.
448    let mut not_handed_back = 0;
449    for child_id in &active_children {
450        let handed_back = backend
451            .subagent_handed_back(child_id.clone())
452            .await
453            .unwrap_or_else(|error| {
454                tracing::warn!(
455                    %session_id,
456                    %child_id,
457                    error = format!("{error:#}"),
458                    "could not tell whether a sub-agent handed back; warning about it"
459                );
460                false
461            });
462        if !handed_back {
463            not_handed_back += 1;
464        }
465    }
466    backend.cancel_start(session_id.clone()).await?;
467    let status = send_action(
468        &state,
469        ControllerAction::Suspend {
470            session_id: session_id.clone(),
471            acknowledge_unpublished_work: request
472                .as_ref()
473                .is_some_and(|r| r.acknowledge_unpublished_work),
474        },
475    )
476    .await?;
477    Ok((
478        status,
479        Json(SuspendSessionResponse {
480            session_id,
481            stopped_subagents: active_children.len(),
482            subagents_not_handed_back: not_handed_back,
483            warning: mj_core::subagent::suspend_warning(not_handed_back),
484        }),
485    ))
486}
487
488#[derive(Debug, Default, serde::Deserialize)]
489#[serde(deny_unknown_fields)]
490pub(super) struct SuspendRequest {
491    /// Older clients send this to suspend a parent with active sub-agents.
492    /// A suspend now always stops them, so it is accepted and ignored.
493    #[serde(default, rename = "acknowledge_active_subagents")]
494    _acknowledge_active_subagents: bool,
495    #[serde(default)]
496    pub(super) acknowledge_unpublished_work: bool,
497}
498
499pub(super) async fn destroy(
500    State(state): State<ServerState>,
501    Path(session_id): Path<String>,
502    request: Option<Json<DestroyRequest>>,
503) -> Result<StatusCode, ApiFailure> {
504    backend(&state)?.cancel_start(session_id.clone()).await?;
505    let delete_branch = request.is_some_and(|r| r.delete_branch);
506    send_action(
507        &state,
508        ControllerAction::Destroy {
509            session_id,
510            delete_branch,
511        },
512    )
513    .await
514}
515
516#[derive(Debug, Default, serde::Deserialize)]
517#[serde(deny_unknown_fields)]
518pub(super) struct DestroyRequest {
519    #[serde(default)]
520    pub(super) delete_branch: bool,
521}
522
523pub(super) async fn interrupt_turn(
524    State(state): State<ServerState>,
525    Path(session_id): Path<String>,
526) -> Result<StatusCode, ApiFailure> {
527    // A prompt still held for a starting session has no turn to stop, so
528    // interrupting the session withdraws it. Only a session that also has a
529    // turn of its own goes on to the daemon.
530    if state.held_prompts.withdraw(&session_id) > 0 {
531        let has_turn = state
532            .snapshot_rx
533            .borrow()
534            .sessions
535            .iter()
536            .any(|session| session.id == session_id && session.capabilities.interrupt_turn);
537        if !has_turn {
538            return Ok(StatusCode::ACCEPTED);
539        }
540    }
541    send_action(&state, ControllerAction::InterruptTurn { session_id }).await
542}
543
544/// Start a one-off review of the turn the session just finished. The
545/// controller answers once the review has opened; a review that cannot start
546/// (the session is still working, prompts are queued, a sub-agent's turn, no
547/// usable reviewer) is refused with the reason, as 409.
548pub(super) async fn start_review(
549    State(state): State<ServerState>,
550    Path(session_id): Path<String>,
551) -> Result<(StatusCode, Json<StartReviewResponse>), ApiFailure> {
552    let status = send_action(
553        &state,
554        ControllerAction::StartReview {
555            session_id: session_id.clone(),
556        },
557    )
558    .await?;
559    Ok((
560        status,
561        Json(StartReviewResponse {
562            session_id,
563            started: true,
564        }),
565    ))
566}
567
568/// Forward, dismiss, or cancel the review the session has open, as the
569/// viewer's buttons do. A resolution the review does not allow (no review is
570/// open, no findings to forward) is refused with the reason, as 409.
571pub(super) async fn resolve_review(
572    State(state): State<ServerState>,
573    Path((session_id, resolution)): Path<(String, String)>,
574) -> Result<(StatusCode, Json<ResolveReviewResponse>), ApiFailure> {
575    if !matches!(resolution.as_str(), "forward" | "dismiss" | "cancel") {
576        return Err(ApiFailure::bad_request(
577            "a review is resolved by forward, dismiss, or cancel",
578        ));
579    }
580    let status = send_action(
581        &state,
582        ControllerAction::ResolveReview {
583            session_id: session_id.clone(),
584            resolution: resolution.clone(),
585        },
586    )
587    .await?;
588    Ok((
589        status,
590        Json(ResolveReviewResponse {
591            session_id,
592            resolution,
593        }),
594    ))
595}
596
597/// The review the session has open, if any, as the viewer shows it.
598pub(super) async fn review_status(
599    State(state): State<ServerState>,
600    Path(session_id): Path<String>,
601) -> Result<Json<ReviewStatusResponse>, ApiFailure> {
602    let review = {
603        let snapshot = state.snapshot_rx.borrow();
604        require_session_record(&snapshot, &session_id)?
605            .turn_review
606            .clone()
607    };
608    Ok(Json(ReviewStatusResponse { session_id, review }))
609}
610
611pub(super) async fn send_action(
612    state: &ServerState,
613    action: ControllerAction,
614) -> Result<StatusCode, ApiFailure> {
615    crate::server::validate_action_live(state, &action).await?;
616    let (reply, outcome) = tokio::sync::oneshot::channel();
617    state
618        .action_tx
619        .send(ControllerRequest { action, reply })
620        .await
621        .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
622    let outcome = outcome
623        .await
624        .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
625    match outcome.rejection() {
626        Some(rejection) => Err(rejection.into()),
627        None => Ok(StatusCode::ACCEPTED),
628    }
629}
630
631/// Cancel queued input without interrupting the current turn or releasing files.
632pub(super) async fn clear_queue(
633    State(state): State<ServerState>,
634    Path(session_id): Path<String>,
635    Json(request): Json<ClearQueueRequest>,
636) -> Result<(StatusCode, Json<PromptResponse>), ApiFailure> {
637    require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
638    let command_id = match request.command_id {
639        Some(id) => id,
640        None => format!("api-clear-queue-{}", mj_core::state::new_session_id()?),
641    };
642    super::super::validate_public_id(&command_id)?;
643    let handle = backend(&state)?
644        .session_handle(session_id)
645        .await?
646        .ok_or_else(|| ApiFailure::conflict("session has no live worker"))?;
647    let turn_id = handle
648        .submit(command_id, mj_core::relay::RelayCommand::ClearQueuedPrompts)
649        .await?;
650    Ok((StatusCode::ACCEPTED, Json(PromptResponse { turn_id })))
651}