Skip to main content

opencode_ctl/tools/
wait.rs

1//! `session_wait`: one evaluation of the anchor, the paged marker search and the terminal-state order.
2
3use std::time::Duration;
4
5use serde::Serialize;
6use serde_json::{json, Map, Value};
7
8use crate::clock::Clock;
9use crate::compact::{compact_form_field, compact_message, compact_permission, Message, ToolsMode};
10use crate::engine::Engine;
11use crate::error::{ErrorCode, ToolError, ToolResult};
12use crate::settings::Settings;
13use crate::transport::{Transport, WaitOutcome};
14
15use super::SessionWaitArgs;
16
17/// The route maximum page size.
18const PAGE_LIMIT: usize = 200;
19/// Hard ceiling of `pending_permissions` / `pending_forms` entries.
20const PENDING_CEILING: usize = 20;
21/// A wait child never blocks longer than this, so the slice deadline and the poll are never starved.
22const CHILD_SLICE_MS: i64 = 30_000;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum WaitState {
27    Idle,
28    Failed,
29    Interrupted,
30    Running,
31    AwaitingPermission,
32    AwaitingInput,
33    Stalled,
34    BudgetExceeded,
35    Timeout,
36}
37
38impl WaitState {
39    fn name(self) -> &'static str {
40        match self {
41            Self::Idle => "idle",
42            Self::Failed => "failed",
43            Self::Interrupted => "interrupted",
44            Self::Running => "running",
45            Self::AwaitingPermission => "awaiting_permission",
46            Self::AwaitingInput => "awaiting_input",
47            Self::Stalled => "stalled",
48            Self::BudgetExceeded => "budget_exceeded",
49            Self::Timeout => "timeout",
50        }
51    }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum AnchorSource {
56    /// The anchor is a parked inbox item, parked since this time.
57    Inbox { parked_since: i64 },
58    /// The anchor is a stored message.
59    Message,
60    /// Neither holds the id: the clock is the newest message's `time.created`.
61    Neither { newest_ms: i64 },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Anchor {
66    pub id: String,
67    pub created_ms: i64,
68    pub source: AnchorSource,
69}
70
71/// Everything one evaluation reads. `messages` run OLDEST FIRST and are never trimmed at the anchor.
72pub struct Snapshot {
73    pub messages: Vec<Message>,
74    pub inbox: Vec<Value>,
75    pub active: bool,
76    pub permissions: Vec<Value>,
77    pub forms: Vec<Value>,
78    pub now_ms: i64,
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct Evaluation {
83    pub state: WaitState,
84    /// Present on the three terminal outcomes: the state's name.
85    pub outcome: Option<String>,
86    /// The failed marker's error, `{name, message}`.
87    pub error: Option<Value>,
88    /// `Some(None)` on a terminal state with no newer user message.
89    pub concurrent_turn: Option<Option<String>>,
90    pub turn_s: Option<i64>,
91}
92
93#[derive(Debug, Default)]
94pub struct PageState {
95    pub newest_cursor_previous: Option<String>,
96}
97
98#[derive(Debug, Serialize)]
99pub struct WaitResult {
100    pub state: WaitState,
101    pub elapsed_s: i64,
102    pub turn_s: Option<i64>,
103    pub inbox_pending: bool,
104    pub tokens: Option<Value>,
105    pub last_assistant_text: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub pending_permissions: Option<Vec<Value>>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub pending_forms: Option<Vec<Value>>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub more_permissions: Option<usize>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub more_forms: Option<usize>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub outcome: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub verify_hint: Option<Value>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub concurrent_turn: Option<Option<String>>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub error: Option<Value>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub parked_s: Option<i64>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub pair: Option<Value>,
126}
127
128fn is_user(m: &Message) -> bool {
129    m.r#type == "user"
130}
131
132fn is_idle(m: &Message) -> bool {
133    m.r#type == "idle"
134}
135
136fn item_id(v: &Value) -> Option<&str> {
137    v.get("id").and_then(Value::as_str)
138}
139
140fn item_created(v: &Value) -> Option<i64> {
141    v.pointer("/time/created").and_then(Value::as_i64)
142}
143
144fn parked(anchor: &Anchor, snap: &Snapshot) -> bool {
145    matches!(anchor.source, AnchorSource::Inbox { .. })
146        && snap
147            .inbox
148            .iter()
149            .any(|i| item_id(i) == Some(anchor.id.as_str()))
150}
151
152/// Start of the current non-idle stretch as seen at `end`: the oldest user message at or before `end`
153/// with no idle marker between it and `end`, or the anchor, whichever is older.
154fn turn_start(anchor: &Anchor, snap: &Snapshot, end: i64) -> Option<i64> {
155    let oldest_open_user = snap
156        .messages
157        .iter()
158        .filter(|u| is_user(u) && u.created <= end)
159        .filter(|u| {
160            !snap
161                .messages
162                .iter()
163                .any(|m| is_idle(m) && m.created > u.created && m.created < end)
164        })
165        .map(|u| u.created)
166        .min();
167    let has_user = snap.messages.iter().any(is_user);
168    if !has_user && !matches!(anchor.source, AnchorSource::Inbox { .. }) {
169        return None;
170    }
171    Some(oldest_open_user.map_or(anchor.created_ms, |u| u.min(anchor.created_ms)))
172}
173
174fn seconds(ms: i64) -> i64 {
175    (ms / 1000).max(0)
176}
177
178/// Rules 3.1 to 3.5 in order, after rule (0) (a parked anchor skips (1) and (4)).
179pub fn evaluate(anchor: &Anchor, snap: &Snapshot, settings: &Settings) -> Evaluation {
180    let parked = parked(anchor, snap);
181    let turn = |end: i64| turn_start(anchor, snap, end).map(|s| seconds(end - s));
182
183    if !parked {
184        let marker = snap
185            .messages
186            .iter()
187            .filter(|m| is_idle(m) && m.created > anchor.created_ms)
188            .min_by_key(|m| m.created);
189        if let Some(marker) = marker {
190            let state = match marker.outcome.as_deref() {
191                Some("failed") => WaitState::Failed,
192                Some("interrupted") => WaitState::Interrupted,
193                _ => WaitState::Idle,
194            };
195            let concurrent = snap
196                .messages
197                .iter()
198                .rev()
199                .find(|m| is_user(m) && m.created > marker.created)
200                .map(|m| m.id.clone());
201            return Evaluation {
202                state,
203                outcome: Some(state.name().to_string()),
204                // `compact_message` has no error field: `message_from_raw` keeps a marker's compacted error in `tools[0]`.
205                error: (state == WaitState::Failed)
206                    .then(|| marker.tools.first().cloned())
207                    .flatten(),
208                concurrent_turn: Some(concurrent),
209                turn_s: turn(marker.created),
210            };
211        }
212    }
213
214    let turn_s = turn(snap.now_ms);
215    let verdict = |state| Evaluation {
216        state,
217        outcome: None,
218        error: None,
219        concurrent_turn: None,
220        turn_s,
221    };
222    if !snap.permissions.is_empty() {
223        return verdict(WaitState::AwaitingPermission);
224    }
225    if !snap.forms.is_empty() {
226        return verdict(WaitState::AwaitingInput);
227    }
228    if turn_s.is_some_and(|t| t > settings.max_turn_s as i64) {
229        return verdict(WaitState::BudgetExceeded);
230    }
231    if !parked && !snap.active {
232        let newest = snap.messages.iter().max_by_key(|m| m.created);
233        let newest_is_marker_after_anchor =
234            newest.is_some_and(|m| is_idle(m) && m.created > anchor.created_ms);
235        // An empty message list has no newest message: the anchor's own clock (the call start, see `locate`) stands in.
236        let clock = match (newest, &anchor.source) {
237            (Some(m), _) => Some(m.created),
238            (None, AnchorSource::Neither { .. }) => Some(anchor.created_ms),
239            (None, _) => None,
240        };
241        if let Some(clock) = clock {
242            if !newest_is_marker_after_anchor
243                && snap.now_ms - clock >= settings.stall_s as i64 * 1000
244            {
245                return verdict(WaitState::Stalled);
246            }
247        }
248    }
249    verdict(WaitState::Running)
250}
251
252/// The text of the last `text` part of the newest post-anchor assistant row that has one.
253fn last_assistant_text(anchor: &Anchor, messages: &[Message]) -> Option<String> {
254    messages
255        .iter()
256        .rev()
257        .filter(|m| m.r#type == "assistant" && m.created > anchor.created_ms)
258        .find_map(|m| m.last_text.clone())
259}
260
261fn message_from_raw(raw: &Value) -> Message {
262    let mut m = compact_message(raw, false, ToolsMode::None);
263    if m.r#type == "idle" {
264        if let Some(e) = raw.get("error") {
265            let name = e.get("name").or_else(|| e.get("type")).cloned();
266            m.tools.push(json!({
267                "name": name.unwrap_or(Value::Null),
268                "message": e.get("message").cloned().unwrap_or(Value::Null),
269            }));
270        }
271    }
272    m
273}
274
275fn push_new(out: &mut Vec<Message>, page: Vec<Message>) {
276    for m in page {
277        if !out.iter().any(|k| k.id == m.id) {
278            out.push(m);
279        }
280    }
281}
282
283fn rows(v: &Value) -> Vec<Value> {
284    v.as_array().cloned().unwrap_or_default()
285}
286
287fn cursor_str(cursor: &Option<Value>, key: &str) -> Option<String> {
288    cursor
289        .as_ref()
290        .and_then(|c| c.get(key))
291        .and_then(Value::as_str)
292        .map(str::to_string)
293}
294
295fn compact_form(raw: &Value) -> Value {
296    let mut o = Map::new();
297    for k in ["id", "title", "metadata"] {
298        if let Some(v) = raw.get(k) {
299            o.insert(k.to_string(), v.clone());
300        }
301    }
302    if let Some(fields) = raw.get("fields").and_then(Value::as_array) {
303        o.insert(
304            "fields".into(),
305            Value::Array(fields.iter().map(compact_form_field).collect()),
306        );
307    }
308    Value::Object(o)
309}
310
311/// At most `PENDING_CEILING` compacted entries and the count beyond them.
312fn ceiling(list: &[Value], compact: fn(&Value) -> Value) -> (Option<Vec<Value>>, Option<usize>) {
313    if list.is_empty() {
314        return (None, None);
315    }
316    let kept: Vec<Value> = list.iter().take(PENDING_CEILING).map(compact).collect();
317    let more = list.len().saturating_sub(PENDING_CEILING);
318    (Some(kept), (more > 0).then_some(more))
319}
320
321enum Stop<'a> {
322    /// Page until a message with this id is seen.
323    Find(&'a str),
324    /// Page until the anchor itself or a message not newer than it is seen.
325    Anchor(&'a Anchor),
326}
327
328impl Stop<'_> {
329    /// The anchor timestamp this stop establishes once `hit` matched `m`.
330    fn ts(&self, m: &Message) -> i64 {
331        match self {
332            Stop::Find(_) => m.created,
333            Stop::Anchor(a) => a.created_ms,
334        }
335    }
336
337    fn hit(&self, m: &Message) -> bool {
338        match self {
339            Stop::Find(id) => m.id == *id,
340            Stop::Anchor(a) => m.id == a.id || m.created <= a.created_ms,
341        }
342    }
343}
344
345/// What anchor resolution leaves for the rest of one evaluation.
346struct Located {
347    anchor: Anchor,
348    messages: Vec<Message>,
349    inbox: Vec<Value>,
350    /// The slice deadline cut the first read short: the slice reports `running`.
351    cut: bool,
352}
353
354/// The two lists one poll tick reads.
355struct Lists {
356    permissions: Vec<Value>,
357    forms: Vec<Value>,
358}
359
360enum PollEnd {
361    /// A tick found a pending permission or form (the slice ends at once).
362    Found(Lists),
363    /// The tick at the deadline; empty lists when that tick failed after a good one.
364    Deadline(Lists),
365    /// Two consecutive failed ticks.
366    Failed(ToolError),
367}
368
369/// What the wait-child task accumulates; it lives outside the task so the slice end can use it.
370struct ChildState {
371    messages: Vec<Message>,
372    pages: PageState,
373}
374
375/// The reads a child return already made, when its evaluation ended the slice.
376struct ChildDone {
377    inbox: Vec<Value>,
378    active: bool,
379}
380
381enum SliceEnd {
382    Child(ChildDone),
383    Poll(Lists),
384}
385
386impl<T: Transport, C: Clock> Engine<T, C> {
387    async fn list_op(
388        &self,
389        op: &str,
390        session: &str,
391    ) -> Result<(Value, Option<Value>, Option<Value>), ToolError> {
392        self.call_json(
393            op,
394            vec![("sessionID".to_string(), session.to_string())],
395            None,
396        )
397        .await
398    }
399
400    /// The first read of a slice: `order=desc limit=200`, following `cursor.next` until `stop` hits or
401    /// the list ends or the clock reaches `cut_at`. Returns the messages OLDEST FIRST, whether `stop` hit and
402    /// whether the deadline cut the search.
403    async fn read_pages(
404        &self,
405        session: &str,
406        stop: Stop<'_>,
407        state: &mut PageState,
408        cut_at: i64,
409    ) -> Result<(Vec<Message>, bool, bool), ToolError> {
410        let mut all: Vec<Message> = Vec::new();
411        let mut cursor: Option<String> = None;
412        let mut first = true;
413        let mut hit_ts: Option<i64> = None;
414        let mut cut = false;
415        let found = loop {
416            let mut params = vec![
417                ("sessionID".to_string(), session.to_string()),
418                ("limit".to_string(), PAGE_LIMIT.to_string()),
419            ];
420            match &cursor {
421                // The cursor carries its own order; the route rejects `order` beside it.
422                Some(c) => params.push(("cursor".into(), c.clone())),
423                None => params.push(("order".into(), "desc".into())),
424            }
425            let (data, cur, _) = self.call_json("session.message.list", params, None).await?;
426            if first {
427                state.newest_cursor_previous = cursor_str(&cur, "previous");
428                first = false;
429            }
430            let page: Vec<Message> = rows(&data).iter().map(message_from_raw).collect();
431            let empty = page.is_empty();
432            if hit_ts.is_none() {
433                hit_ts = page.iter().find(|m| stop.hit(m)).map(|m| stop.ts(m));
434            }
435            all.extend(page);
436            // After the stop hits, keep paging until an idle marker older than the anchor has been read, so the
437            // whole current non-idle stretch (`turn_s`) is in the snapshot even when the turn spans pages.
438            let done = hit_ts.is_some_and(|ts| all.iter().any(|m| is_idle(m) && m.created < ts));
439            match cursor_str(&cur, "next") {
440                // The slice deadline cuts a search that still has pages to read: the slice reports `running`
441                // and the next one reads again (spec step 2: "a read cut off by the deadline reports `running`").
442                Some(_) if !done && !empty && self.clock().now_ms() >= cut_at => {
443                    cut = true;
444                    break hit_ts.is_some();
445                }
446                Some(next) if !done && !empty => cursor = Some(next),
447                _ => break hit_ts.is_some(),
448            }
449        };
450        all.reverse();
451        Ok((all, found, cut))
452    }
453
454    /// Messages not read yet, OLDEST FIRST. The first read of a slice (`state` empty) pages back to the
455    /// anchor; later reads are one native "newer than" read from `cursor.previous` of the newest page seen.
456    pub async fn read_messages_since(
457        &self,
458        session: &str,
459        anchor: &Anchor,
460        state: &mut PageState,
461    ) -> Result<Vec<Message>, ToolError> {
462        let mut out = Vec::new();
463        self.read_incremental(session, anchor, state, &mut out)
464            .await?;
465        Ok(out)
466    }
467
468    /// `read_messages_since` appending into `out` page by page, so a read dropped mid-way (the poll ended the
469    /// slice) never loses a page the cursor has already moved past. A message already in `out` is not added twice.
470    async fn read_incremental(
471        &self,
472        session: &str,
473        anchor: &Anchor,
474        state: &mut PageState,
475        out: &mut Vec<Message>,
476    ) -> Result<(), ToolError> {
477        let Some(mut cursor) = state.newest_cursor_previous.clone() else {
478            let (page, _, _) = self
479                .read_pages(session, Stop::Anchor(anchor), state, i64::MAX)
480                .await?;
481            push_new(out, page);
482            return Ok(());
483        };
484        loop {
485            let params = vec![
486                ("sessionID".to_string(), session.to_string()),
487                ("limit".to_string(), PAGE_LIMIT.to_string()),
488                ("cursor".to_string(), cursor.clone()),
489            ];
490            let (data, cur, _) = self.call_json("session.message.list", params, None).await?;
491            let mut page: Vec<Message> = rows(&data).iter().map(message_from_raw).collect();
492            let full = page.len() >= PAGE_LIMIT;
493            if page.is_empty() {
494                break;
495            }
496            page.reverse();
497            push_new(out, page);
498            match cursor_str(&cur, "previous") {
499                Some(prev) => {
500                    state.newest_cursor_previous = Some(prev.clone());
501                    if !full {
502                        break;
503                    }
504                    cursor = prev;
505                }
506                None => break,
507            }
508        }
509        Ok(())
510    }
511
512    /// The anchor of this slice, or `None` when the session has no user message and no `after` was given.
513    /// `cut_at` is the slice deadline: a first read still paging at that time stops and sets `cut`.
514    async fn locate(
515        &self,
516        session: &str,
517        after: Option<&str>,
518        call_start: i64,
519        cut_at: i64,
520        state: &mut PageState,
521    ) -> Result<Option<Located>, ToolError> {
522        let Some(after) = after else {
523            let params = vec![
524                ("sessionID".to_string(), session.to_string()),
525                ("order".to_string(), "desc".to_string()),
526                ("type".to_string(), "user".to_string()),
527                ("limit".to_string(), "1".to_string()),
528            ];
529            let (data, _, _) = self.call_json("session.message.list", params, None).await?;
530            let Some(newest_user) = rows(&data).first().map(message_from_raw) else {
531                return Ok(None);
532            };
533            let anchor = Anchor {
534                id: newest_user.id,
535                created_ms: newest_user.created,
536                source: AnchorSource::Message,
537            };
538            let (messages, _, cut) = self
539                .read_pages(session, Stop::Anchor(&anchor), state, cut_at)
540                .await?;
541            // A stored message is never parked: the inbox is read on every child return and at the slice end.
542            return Ok(Some(Located {
543                anchor,
544                messages,
545                inbox: Vec::new(),
546                cut,
547            }));
548        };
549
550        let (inbox, _, _) = self.list_op("session.inbox.list", session).await?;
551        let inbox = rows(&inbox);
552        if let Some(created) = inbox
553            .iter()
554            .find(|i| item_id(i) == Some(after))
555            .and_then(item_created)
556        {
557            let anchor = Anchor {
558                id: after.to_string(),
559                created_ms: created,
560                source: AnchorSource::Inbox {
561                    parked_since: created,
562                },
563            };
564            let (messages, _, cut) = self
565                .read_pages(session, Stop::Anchor(&anchor), state, cut_at)
566                .await?;
567            return Ok(Some(Located {
568                anchor,
569                messages,
570                inbox,
571                cut,
572            }));
573        }
574
575        // Not parked: it is a stored message, or it is nowhere and the newest message's clock stands in.
576        let (messages, found, cut) = self
577            .read_pages(session, Stop::Find(after), state, cut_at)
578            .await?;
579        let anchor = match messages.iter().find(|m| m.id == after) {
580            Some(m) if found => Anchor {
581                id: after.to_string(),
582                created_ms: m.created,
583                source: AnchorSource::Message,
584            },
585            _ => {
586                let newest_ms = messages
587                    .iter()
588                    .map(|m| m.created)
589                    .max()
590                    // RULING: an unknown `after` on an EMPTY message list uses the call-start time as its stall
591                    // clock, so the wait reaches `stalled` after `stall_s` instead of staying `running` forever.
592                    .unwrap_or(call_start);
593                Anchor {
594                    id: after.to_string(),
595                    created_ms: newest_ms,
596                    source: AnchorSource::Neither { newest_ms },
597                }
598            }
599        };
600        Ok(Some(Located {
601            anchor,
602            messages,
603            inbox,
604            cut,
605        }))
606    }
607
608    /// Explicit `after` never replaced; else the newest `user` message. A session with no user message and
609    /// no `after` has no anchor: `session_wait` answers `idle` before this point, so here it is `INVALID_REQUEST`.
610    /// Delegates to `locate`, the one anchor path `session_wait` itself uses.
611    pub async fn resolve_anchor(
612        &self,
613        session: &str,
614        after: Option<&str>,
615    ) -> Result<Anchor, ToolError> {
616        let mut state = PageState::default();
617        self.locate(session, after, self.clock().now_ms(), i64::MAX, &mut state)
618            .await?
619            .map(|l| l.anchor)
620            .ok_or_else(|| {
621                ToolError::new(
622                    ErrorCode::InvalidRequest,
623                    "the session has no user message to anchor on",
624                )
625            })
626    }
627
628    /// Whether the session is a key of `session.active`'s `data` map. The route lists every active session and
629    /// declares no parameter, so no `sessionID` is sent.
630    async fn is_active(&self, session: &str) -> Result<bool, ToolError> {
631        let (data, _, _) = self.call_json("session.active", Vec::new(), None).await?;
632        Ok(data.get(session).is_some())
633    }
634
635    async fn tokens_of(&self, session: &str) -> Option<Value> {
636        let (s, _, _) = self.list_op("session.get", session).await.ok()?;
637        Some(s.get("tokens").cloned().unwrap_or(Value::Null))
638    }
639
640    /// One poll tick: both stall channels. Either failing is a failed tick.
641    async fn poll_once(&self, session: &str) -> Result<Lists, ToolError> {
642        let (permissions, _, _) = self.list_op("session.permission.list", session).await?;
643        let (forms, _, _) = self.list_op("session.form.list", session).await?;
644        Ok(Lists {
645            permissions: rows(&permissions),
646            forms: rows(&forms),
647        })
648    }
649
650    /// Task (b): ticks at the slice start, at every multiple of `poll_ms` strictly before the deadline, and at
651    /// the deadline. A failed tick is retried at the next one; two in a row end the slice with the last error.
652    async fn poll_loop(
653        &self,
654        session: &str,
655        started: i64,
656        timeout_ms: i64,
657        poll_ms: i64,
658    ) -> PollEnd {
659        let mut failed = 0u8;
660        loop {
661            let at_deadline = self.clock().now_ms() - started >= timeout_ms;
662            match self.poll_once(session).await {
663                Ok(lists) => {
664                    failed = 0;
665                    if !lists.permissions.is_empty() || !lists.forms.is_empty() {
666                        return PollEnd::Found(lists);
667                    }
668                    if at_deadline {
669                        return PollEnd::Deadline(lists);
670                    }
671                }
672                Err(e) => {
673                    failed += 1;
674                    if failed >= 2 {
675                        return PollEnd::Failed(e);
676                    }
677                    if at_deadline {
678                        return PollEnd::Deadline(Lists {
679                            permissions: Vec::new(),
680                            forms: Vec::new(),
681                        });
682                    }
683                }
684            }
685            let elapsed = self.clock().now_ms() - started;
686            let next = (elapsed / poll_ms + 1) * poll_ms;
687            let target = next.min(timeout_ms);
688            self.clock()
689                .sleep(Duration::from_millis((target - elapsed).max(0) as u64))
690                .await;
691        }
692    }
693
694    /// Task (a): the wait child, restarted until a return whose evaluation is not `Running`. Each return costs the
695    /// inbox, `session.active` and one incremental message read besides the child itself.
696    async fn child_loop(
697        &self,
698        session: &str,
699        anchor: &Anchor,
700        deadline_ms: i64,
701        poll_ms: i64,
702        cs: &mut ChildState,
703    ) -> Result<ChildDone, ToolError> {
704        loop {
705            let remaining = deadline_ms - self.clock().now_ms();
706            if remaining <= 0 {
707                // The poll's deadline tick ends the slice.
708                return std::future::pending().await;
709            }
710            let outcome = self
711                .wait_idle(
712                    session,
713                    Duration::from_millis(remaining.min(CHILD_SLICE_MS) as u64),
714                )
715                .await?;
716            let (inbox, _, _) = self.list_op("session.inbox.list", session).await?;
717            let active = self.is_active(session).await?;
718            self.read_incremental(session, anchor, &mut cs.pages, &mut cs.messages)
719                .await?;
720            let snap = Snapshot {
721                messages: cs.messages.clone(),
722                inbox: rows(&inbox),
723                active,
724                // A pending permission or form has already ended the slice from the poll.
725                permissions: Vec::new(),
726                forms: Vec::new(),
727                now_ms: self.clock().now_ms(),
728            };
729            if evaluate(anchor, &snap, &self.settings).state != WaitState::Running {
730                return Ok(ChildDone {
731                    inbox: snap.inbox,
732                    active: snap.active,
733                });
734            }
735            if outcome == WaitOutcome::Idle {
736                // The route answers at once when the coordinator holds no execution: do not spin.
737                self.clock()
738                    .sleep(Duration::from_millis(poll_ms as u64))
739                    .await;
740            }
741        }
742    }
743
744    fn pair_of(&self) -> Option<Value> {
745        // As `service_status.paired.{tui,claude}`, re-read from the pair files on every call.
746        self.env.session.as_ref().map(|_| {
747            let (tui, claude) = self.pair_liveness();
748            json!({"tui": tui.as_str(), "claude": claude.as_str()})
749        })
750    }
751
752    pub async fn session_wait(&self, a: SessionWaitArgs) -> ToolResult {
753        let session = self.resolve_session(a.session_id.as_deref())?;
754        self.guard_created(&session)?;
755        for (name, v) in [("timeout_s", a.timeout_s), ("poll_s", a.poll_s)] {
756            if v == Some(0) {
757                return Err(ToolError::new(
758                    ErrorCode::InvalidArgument,
759                    format!("{name} must be at least 1"),
760                ));
761            }
762        }
763        let timeout_s = a
764            .timeout_s
765            .unwrap_or(self.settings.wait_timeout_s)
766            .min(self.settings.wait_max_s);
767        let poll_s = a.poll_s.unwrap_or(self.settings.poll_s).max(1);
768        let timeout_ms = timeout_s as i64 * 1000;
769        let poll_ms = poll_s as i64 * 1000;
770        let started = self.clock().now_ms();
771        let deadline_ms = started + timeout_ms;
772        let pair = self.pair_of();
773
774        let mut cs = ChildState {
775            messages: Vec::new(),
776            pages: PageState::default(),
777        };
778        let Some(located) = self
779            .locate(
780                &session,
781                a.after.as_deref(),
782                started,
783                deadline_ms,
784                &mut cs.pages,
785            )
786            .await?
787        else {
788            let tokens = self.tokens_of(&session).await;
789            let result = WaitResult {
790                state: WaitState::Idle,
791                elapsed_s: seconds(self.clock().now_ms() - started),
792                turn_s: None,
793                inbox_pending: false,
794                tokens: tokens.filter(|t| !t.is_null()),
795                last_assistant_text: None,
796                pending_permissions: None,
797                pending_forms: None,
798                more_permissions: None,
799                more_forms: None,
800                outcome: Some("idle".into()),
801                verify_hint: None,
802                concurrent_turn: Some(None),
803                error: None,
804                parked_s: None,
805                pair,
806            };
807            return Ok(serde_json::to_value(result).unwrap_or(Value::Null));
808        };
809        let Located {
810            anchor,
811            messages,
812            inbox,
813            cut,
814        } = located;
815        cs.messages = messages;
816
817        let (inbox, active, lists) = if cut {
818            // The search used the whole slice: report `running`; the next slice reads again.
819            let none = Lists {
820                permissions: Vec::new(),
821                forms: Vec::new(),
822            };
823            (inbox, true, none)
824        } else {
825            let has_wait_route = self.op("experimental.session.wait").await.is_ok();
826            let end = {
827                let poll = self.poll_loop(&session, started, timeout_ms, poll_ms);
828                let child = async {
829                    if has_wait_route {
830                        self.child_loop(&session, &anchor, deadline_ms, poll_ms, &mut cs)
831                            .await
832                    } else {
833                        std::future::pending().await
834                    }
835                };
836                tokio::select! {
837                    biased;
838                    p = poll => match p {
839                        PollEnd::Failed(e) => return Err(e),
840                        PollEnd::Found(l) | PollEnd::Deadline(l) => SliceEnd::Poll(l),
841                    },
842                    c = child => SliceEnd::Child(c?),
843                }
844            };
845            match end {
846                SliceEnd::Child(done) => (
847                    done.inbox,
848                    done.active,
849                    Lists {
850                        permissions: Vec::new(),
851                        forms: Vec::new(),
852                    },
853                ),
854                SliceEnd::Poll(lists) => {
855                    // Slice-end reads: each propagates its mapped error (a failed `session.active` is an error,
856                    // never `active = false`), and the error wins even over a permission the poll found.
857                    self.read_incremental(&session, &anchor, &mut cs.pages, &mut cs.messages)
858                        .await?;
859                    let (inbox, _, _) = self.list_op("session.inbox.list", &session).await?;
860                    let active = self.is_active(&session).await?;
861                    (rows(&inbox), active, lists)
862                }
863            }
864        };
865        // The only slice-end read that degrades.
866        let tokens = self.tokens_of(&session).await;
867
868        let snap = Snapshot {
869            messages: std::mem::take(&mut cs.messages),
870            inbox,
871            active,
872            permissions: lists.permissions,
873            forms: lists.forms,
874            now_ms: self.clock().now_ms(),
875        };
876        let mut ev = evaluate(&anchor, &snap, &self.settings);
877        if cut {
878            // A partial snapshot decides nothing: the result has exactly a running result's keys.
879            ev.state = WaitState::Running;
880            ev.outcome = None;
881            ev.error = None;
882            ev.concurrent_turn = None;
883            // As a running result: the stretch runs to now, never to a marker in the partial snapshot.
884            ev.turn_s = turn_start(&anchor, &snap, snap.now_ms).map(|s| seconds(snap.now_ms - s));
885        }
886        if ev.state == WaitState::Running && a.hard == Some(true) {
887            ev.state = WaitState::Timeout;
888        }
889
890        let parked_s = if snap.inbox.is_empty() {
891            None
892        } else {
893            let since = match anchor.source {
894                AnchorSource::Inbox { parked_since } if parked(&anchor, &snap) => {
895                    Some(parked_since)
896                }
897                _ => snap.inbox.iter().filter_map(item_created).min(),
898            };
899            since.map(|s| seconds(snap.now_ms - s))
900        };
901        let (pending_permissions, more_permissions) =
902            ceiling(&snap.permissions, compact_permission);
903        let (pending_forms, more_forms) = ceiling(&snap.forms, compact_form);
904        let result = WaitResult {
905            state: ev.state,
906            elapsed_s: seconds(snap.now_ms - started),
907            turn_s: ev.turn_s,
908            inbox_pending: !snap.inbox.is_empty(),
909            tokens,
910            last_assistant_text: last_assistant_text(&anchor, &snap.messages),
911            pending_permissions,
912            pending_forms,
913            more_permissions,
914            more_forms,
915            verify_hint: (ev.state == WaitState::Idle).then(|| {
916                json!({
917                    "tool": "session_changes",
918                    "args": {"session_id": session, "from": anchor.id},
919                })
920            }),
921            outcome: ev.outcome,
922            concurrent_turn: ev.concurrent_turn,
923            error: ev.error,
924            parked_s,
925            pair,
926        };
927        Ok(serde_json::to_value(result).unwrap_or(Value::Null))
928    }
929}