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 usage_tree(
342    State(state): State<ServerState>,
343    Path(session_id): Path<String>,
344) -> Result<Json<crate::database::UsageTree>, ApiFailure> {
345    let tree = backend(&state)?
346        .usage_tree(session_id)
347        .await?
348        .ok_or_else(|| ApiFailure::not_found("no accounting identity exists for that session"))?;
349    Ok(Json(tree))
350}
351
352pub(super) async fn transcript(
353    State(state): State<ServerState>,
354    Path(session_id): Path<String>,
355    Query(query): Query<TranscriptQuery>,
356) -> Result<Json<TranscriptResponse>, ApiFailure> {
357    let backend = backend(&state)?.clone();
358    let limit = query
359        .limit
360        .unwrap_or(DEFAULT_TRANSCRIPT_LIMIT)
361        .clamp(1, MAX_TRANSCRIPT_LIMIT);
362    let roles = if query.finished_only {
363        if query
364            .role
365            .iter()
366            .any(|role| *role != mj_core::transcript::TranscriptRole::Agent)
367        {
368            return Err(ApiFailure::bad_request(
369                "finished_only can only be combined with role=agent",
370            ));
371        }
372        vec![mj_core::transcript::TranscriptRole::Agent]
373    } else {
374        query.role
375    };
376    let page = backend
377        .transcript(
378            session_id.clone(),
379            query.after_seq.unwrap_or(0),
380            limit,
381            roles,
382            query.finished_only,
383        )
384        .await?
385        .ok_or_else(|| ApiFailure::not_found("no transcript is recorded for that session"))?;
386    Ok(Json(TranscriptResponse {
387        next_after_seq: page.next_after_seq,
388        session_id,
389        latest_seq: page.latest_seq,
390        execution: page.execution,
391        items: page
392            .items
393            .iter()
394            .map(|item| TranscriptItemView {
395                stable_id: item.stable_id.clone(),
396                position: item.position,
397                seq: item.seq(),
398                role: mj_core::transcript::transcript_item_role(&item.body).to_owned(),
399                text: mj_transcript::transcript::transcript_item_text(item),
400                created_at_ms: item.created_at_ms,
401                last_changed_at_ms: item.last_changed_at_ms,
402                body: item.body.clone(),
403            })
404            .collect(),
405    }))
406}
407
408/// Suspend a session. Its active Mjolnir sub-agents are stopped without a
409/// checkpoint and the session is suspended alone; the answer warns when some
410/// of those sub-agents have not handed back their reports. Accepting the
411/// suspend is not finishing it.
412pub(super) async fn suspend(
413    State(state): State<ServerState>,
414    Path(session_id): Path<String>,
415    request: Option<Json<SuspendRequest>>,
416) -> Result<(StatusCode, Json<SuspendSessionResponse>), ApiFailure> {
417    let (active_children, publication_state) = {
418        let snapshot = state.snapshot_rx.borrow();
419        let session = require_session_record(&snapshot, &session_id)?;
420        let active_children = session
421            .subagent_session_ids
422            .iter()
423            .filter(|id| {
424                snapshot
425                    .sessions
426                    .iter()
427                    .any(|child| child.id == id.as_str() && child.lifecycle.is_dashboard_visible())
428            })
429            .cloned()
430            .collect::<Vec<_>>();
431        (active_children, session.publication_state)
432    };
433    // The controller re-checks publication after its checkpoint; this is only
434    // an early refusal that avoids a needless checkpoint.
435    if publication_state.is_some()
436        && !request
437            .as_ref()
438            .is_some_and(|r| r.acknowledge_unpublished_work)
439    {
440        return Err(ApiFailure::conflict(
441            "publication status is unverified for this live clone; retry with acknowledge_unpublished_work=true to suspend it",
442        ));
443    }
444    let backend = backend(&state)?.clone();
445    // Counted before the suspend is sent, which is what stops the children.
446    let mut not_handed_back = 0;
447    for child_id in &active_children {
448        let handed_back = backend
449            .subagent_handed_back(child_id.clone())
450            .await
451            .unwrap_or_else(|error| {
452                tracing::warn!(
453                    %session_id,
454                    %child_id,
455                    error = format!("{error:#}"),
456                    "could not tell whether a sub-agent handed back; warning about it"
457                );
458                false
459            });
460        if !handed_back {
461            not_handed_back += 1;
462        }
463    }
464    backend.cancel_start(session_id.clone()).await?;
465    let status = send_action(
466        &state,
467        ControllerAction::Suspend {
468            session_id: session_id.clone(),
469            acknowledge_unpublished_work: request
470                .as_ref()
471                .is_some_and(|r| r.acknowledge_unpublished_work),
472        },
473    )
474    .await?;
475    Ok((
476        status,
477        Json(SuspendSessionResponse {
478            session_id,
479            stopped_subagents: active_children.len(),
480            subagents_not_handed_back: not_handed_back,
481            warning: mj_core::subagent::suspend_warning(not_handed_back),
482        }),
483    ))
484}
485
486#[derive(Debug, Default, serde::Deserialize)]
487#[serde(deny_unknown_fields)]
488pub(super) struct SuspendRequest {
489    /// Older clients send this to suspend a parent with active sub-agents.
490    /// A suspend now always stops them, so it is accepted and ignored.
491    #[serde(default, rename = "acknowledge_active_subagents")]
492    _acknowledge_active_subagents: bool,
493    #[serde(default)]
494    pub(super) acknowledge_unpublished_work: bool,
495}
496
497pub(super) async fn destroy(
498    State(state): State<ServerState>,
499    Path(session_id): Path<String>,
500    request: Option<Json<DestroyRequest>>,
501) -> Result<StatusCode, ApiFailure> {
502    backend(&state)?.cancel_start(session_id.clone()).await?;
503    let delete_branch = request.is_some_and(|r| r.delete_branch);
504    send_action(
505        &state,
506        ControllerAction::Destroy {
507            session_id,
508            delete_branch,
509        },
510    )
511    .await
512}
513
514#[derive(Debug, Default, serde::Deserialize)]
515#[serde(deny_unknown_fields)]
516pub(super) struct DestroyRequest {
517    #[serde(default)]
518    pub(super) delete_branch: bool,
519}
520
521pub(super) async fn interrupt_turn(
522    State(state): State<ServerState>,
523    Path(session_id): Path<String>,
524) -> Result<StatusCode, ApiFailure> {
525    // A prompt still held for a starting session has no turn to stop, so
526    // interrupting the session withdraws it. Only a session that also has a
527    // turn of its own goes on to the daemon.
528    if state.held_prompts.withdraw(&session_id) > 0 {
529        let has_turn = state
530            .snapshot_rx
531            .borrow()
532            .sessions
533            .iter()
534            .any(|session| session.id == session_id && session.capabilities.interrupt_turn);
535        if !has_turn {
536            return Ok(StatusCode::ACCEPTED);
537        }
538    }
539    send_action(&state, ControllerAction::InterruptTurn { session_id }).await
540}
541
542/// Start a one-off review of the turn the session just finished. The
543/// controller answers once the review has opened; a review that cannot start
544/// (the session is still working, prompts are queued, a sub-agent's turn, no
545/// usable reviewer) is refused with the reason, as 409.
546pub(super) async fn start_review(
547    State(state): State<ServerState>,
548    Path(session_id): Path<String>,
549) -> Result<(StatusCode, Json<StartReviewResponse>), ApiFailure> {
550    let status = send_action(
551        &state,
552        ControllerAction::StartReview {
553            session_id: session_id.clone(),
554        },
555    )
556    .await?;
557    Ok((
558        status,
559        Json(StartReviewResponse {
560            session_id,
561            started: true,
562        }),
563    ))
564}
565
566/// Forward, dismiss, or cancel the review the session has open, as the
567/// viewer's buttons do. A resolution the review does not allow (no review is
568/// open, no findings to forward) is refused with the reason, as 409.
569pub(super) async fn resolve_review(
570    State(state): State<ServerState>,
571    Path((session_id, resolution)): Path<(String, String)>,
572) -> Result<(StatusCode, Json<ResolveReviewResponse>), ApiFailure> {
573    if !matches!(resolution.as_str(), "forward" | "dismiss" | "cancel") {
574        return Err(ApiFailure::bad_request(
575            "a review is resolved by forward, dismiss, or cancel",
576        ));
577    }
578    let status = send_action(
579        &state,
580        ControllerAction::ResolveReview {
581            session_id: session_id.clone(),
582            resolution: resolution.clone(),
583        },
584    )
585    .await?;
586    Ok((
587        status,
588        Json(ResolveReviewResponse {
589            session_id,
590            resolution,
591        }),
592    ))
593}
594
595/// The review the session has open, if any, as the viewer shows it.
596pub(super) async fn review_status(
597    State(state): State<ServerState>,
598    Path(session_id): Path<String>,
599) -> Result<Json<ReviewStatusResponse>, ApiFailure> {
600    let review = {
601        let snapshot = state.snapshot_rx.borrow();
602        require_session_record(&snapshot, &session_id)?
603            .turn_review
604            .clone()
605    };
606    Ok(Json(ReviewStatusResponse { session_id, review }))
607}
608
609pub(super) async fn send_action(
610    state: &ServerState,
611    action: ControllerAction,
612) -> Result<StatusCode, ApiFailure> {
613    crate::server::validate_action_live(state, &action).await?;
614    let (reply, outcome) = tokio::sync::oneshot::channel();
615    state
616        .action_tx
617        .send(ControllerRequest { action, reply })
618        .await
619        .map_err(|_| ApiFailure::unavailable("the controller is not accepting actions"))?;
620    let outcome = outcome
621        .await
622        .map_err(|_| ApiFailure::unavailable("the controller dropped this action"))?;
623    match outcome.rejection() {
624        Some(rejection) => Err(rejection.into()),
625        None => Ok(StatusCode::ACCEPTED),
626    }
627}