Skip to main content

runtime/run/
lifecycle.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What an active run does on the ledger: the dispatch pair that brings
7//! it into existence, one turn, and the freeze that is its only exit.
8
9use kernel::{
10    AxError, Completion, EventDraft, EventKind, Evidence, Ledger, Model, Payload, StopReason,
11};
12
13use crate::conversation::Conversation;
14use crate::handoff::Handoff;
15use crate::prefix::shape::PromptShape;
16use crate::reminder::ContextGauge;
17use crate::turn::{Generating, Interrupt, PhaseOutcome, RunPrompt, Turn, TurnReport};
18
19use super::checkpoint::{CheckpointPolicy, Wave, WaveCheckpoint};
20use super::{Active, Advance, Frozen, Run, RunHooks, RunPlan, SafePoint};
21
22/// A steer changes what the model reads next, so the driver folds it into
23/// the window it owns; the turn layer records that it arrived. Two halves
24/// of one event, each held where its material is: the text belongs to the
25/// window, the record belongs to the ledger.
26///
27/// Whichever safe point it arrives at, the fold takes effect at the next
28/// assembly โ€” a steer never rewrites a request already on the wire, which
29/// the conversation holds by knowing what the last assembly sent.
30fn fold_steer(conversation: &mut Conversation, interrupt: &Interrupt) {
31    if let Interrupt::Steer { source, text } = interrupt {
32        conversation.push_steer(source, text);
33    }
34}
35
36/// How a turn that called no tool ends the run.
37///
38/// **A reply with nothing in it is not evidence that work finished.**
39/// Two replies arrive that way: one the provider cut off at the model's
40/// output ceiling, and one that carries no content at all โ€” which is
41/// what a request stating a ceiling of zero earns from a provider that
42/// accepts the number. Freezing either as done tells a person their work
43/// is finished at the moment nothing was said, and cites a
44/// `model_returned` holding no content as the evidence for it. Both are
45/// `Limit`: the run ended against something rather than at the end of
46/// its work, and the account says which.
47fn concluded(report: &TurnReport) -> Result<Completion, AxError> {
48    if report.assistant().is_empty() || report.stop() == Some(StopReason::MaxTokens) {
49        return Ok(Completion::Limit);
50    }
51    Ok(Completion::Done(Evidence::new(vec![
52        *report.model_returned(),
53    ])?))
54}
55
56impl Run<Active> {
57    /// The dispatch pair: the job pin lands first, then the run exists.
58    /// Two ledger lines, two clock samples โ€” the pin is a fact about the
59    /// city and the start is a fact about the run. Written by the plan's
60    /// charter, the one author of every run's opening (`crates/runtime/spec/Run.lean`
61    /// ยง8-52).
62    ///
63    /// # Errors
64    /// Propagates whatever the ledger says; nothing else here can fail.
65    pub fn dispatch(
66        plan: RunPlan,
67        ledger: &mut dyn Ledger,
68        hooks: &mut RunHooks<'_>,
69    ) -> Result<Run<Active>, AxError> {
70        plan.charter().open(ledger, &mut *hooks.now)?;
71
72        let mut conversation = Conversation::new();
73        // A branch opens with the conversation it branched from, and then
74        // its own lines: the task joins the mother's last user message when
75        // one is open, which is the same rule a steer follows.
76        conversation.push_inherited(&plan.inherited);
77        conversation.push_task_lines(&plan.task, &plan.goal, plan.opening);
78        let gauge = ContextGauge::new(
79            kernel::Tokens::new(plan.shape.context_tokens),
80            plan.second_threshold,
81        );
82        Ok(Run {
83            plan,
84            state: Active {
85                conversation,
86                turns: 0,
87                last_turn_t: None,
88                gauge,
89                prior_shape: None,
90                prompt: crate::turn::PromptRecord::default(),
91                checkpoint: CheckpointPolicy::opening(),
92            },
93        })
94    }
95
96    /// Takes one turn. Every exit from a phase is either the next phase or
97    /// a cancellation, so the three safe points are the only places an
98    /// interruption can land.
99    ///
100    /// # Errors
101    /// Propagates ledger and provider failures. A tool failure is not one
102    /// of them: it reaches the model as this call's result.
103    pub fn advance(
104        &mut self,
105        ledger: &mut dyn Ledger,
106        model: &mut dyn Model,
107        hooks: &mut RunHooks<'_>,
108    ) -> Result<Advance, AxError> {
109        let index = self.state.turns;
110        let t = (hooks.now)()?;
111        self.state.last_turn_t = Some(t);
112
113        let turn = Turn::begin(self.plan.run, self.plan.who.clone(), t, &mut *hooks.now);
114        let opening = (hooks.interrupt)(SafePoint::BeforeAssemble { turn: index });
115        fold_steer(&mut self.state.conversation, &opening);
116        let turn = match turn.assemble(
117            opening,
118            ledger,
119            RunPrompt::new(&self.plan.prefix, &mut self.state.prompt),
120            &self.state.conversation,
121            &self.plan.tools,
122            &self.plan.shape,
123        )? {
124            PhaseOutcome::Advanced(next) => next,
125            PhaseOutcome::Cancelled(_) => return Ok(Advance::Concluded(Completion::Cancelled)),
126        };
127        // What this request looks like to a prompt cache, and which of its
128        // regions moved since the request before it. Written here, after the
129        // turn has assembled, so the line describes a request that exists;
130        // the shape of the first request says so rather than claiming a
131        // comparison nobody made.
132        let shape = PromptShape::of(
133            &self.plan.prefix,
134            &self.plan.tools,
135            self.state.conversation.messages(),
136        )?;
137        let changed = shape.attribute(self.state.prior_shape.as_ref());
138        ledger.append(EventDraft {
139            run: self.plan.run,
140            t,
141            who: self.plan.who.clone(),
142            addr: Some(self.plan.addr.clone()),
143            kind: EventKind::PromptShapeCompared,
144            data: Payload::of(&shape.recorded(changed)?)?,
145            ig: false,
146        })?;
147        self.state.prior_shape = Some(shape);
148        let calling = (hooks.interrupt)(SafePoint::BeforeCall { turn: index });
149        let called = turn.call(
150            calling.clone(),
151            ledger,
152            model,
153            &self.plan.policy,
154            // Reborrowed rather than moved: the sink and the tool face
155            // belong to the hooks and every later turn needs them too.
156            Generating::Speculating {
157                deltas: hooks.deltas.as_deref_mut(),
158                tools: &*hooks.invoke,
159            },
160        );
161        // The request borrowed the conversation until the call returned,
162        // so the conversation moves only now: what was assembled is on
163        // the wire, and a steer that arrived before the call joins the
164        // next request, whatever the call answered.
165        self.state.conversation.mark_sent();
166        fold_steer(&mut self.state.conversation, &calling);
167        let turn = match called? {
168            PhaseOutcome::Advanced(next) => next,
169            PhaseOutcome::Cancelled(_) => return Ok(Advance::Concluded(Completion::Cancelled)),
170        };
171
172        // Recorded before the wave, so a `status` call in it reports the
173        // count of the very call that asked for it.
174        if let Some(usage) = turn.usage() {
175            self.plan.context.record(usage.input_tokens);
176        }
177        // The checkpoint goes up before the wave, not before a suspicious call:
178        // anything the wave deletes then has a commit to come back from.
179        let touches = Wave::of(turn.calls(), hooks.writes);
180        let decided = self.state.checkpoint.for_wave(touches);
181        if let (WaveCheckpoint::Stage, Some(checkpoint)) = (decided, hooks.checkpoint.as_mut()) {
182            let committed = checkpoint(t)?;
183            ledger.append(EventDraft {
184                run: self.plan.run,
185                t,
186                who: self.plan.who.clone(),
187                addr: Some(self.plan.addr.clone()),
188                kind: EventKind::CheckpointCommitted,
189                data: committed,
190                ig: false,
191            })?;
192        }
193
194        self.state.checkpoint.record_wave(decided, touches);
195
196        let wave = (hooks.interrupt)(SafePoint::BeforeWave { turn: index });
197        fold_steer(&mut self.state.conversation, &wave);
198        // The same question the three phase boundaries ask, asked again
199        // before each call of the wave. A cancel ends the wave there; a
200        // steer is recorded by the turn and folded into the window, and
201        // the call goes ahead, because text that redirects the work takes
202        // effect at the next assembly and stopping is the only
203        // instruction that can be carried out between two calls.
204        let asking = &mut hooks.interrupt;
205        let conversation = &mut self.state.conversation;
206        let mut still_going = |call: u32| {
207            let arrived = asking(SafePoint::BeforeToolCall { turn: index, call });
208            fold_steer(conversation, &arrived);
209            arrived
210        };
211        let turn =
212            match turn.execute_concurrent(wave, ledger, &mut *hooks.invoke, &mut still_going)? {
213                PhaseOutcome::Advanced(next) => next,
214                PhaseOutcome::Cancelled(_) => return Ok(Advance::Concluded(Completion::Cancelled)),
215            };
216
217        let settling = (hooks.interrupt)(SafePoint::BeforeSpawn { turn: index });
218        fold_steer(&mut self.state.conversation, &settling);
219        let report = match turn.record(settling, ledger)? {
220            PhaseOutcome::Advanced(report) => report,
221            PhaseOutcome::Cancelled(_) => return Ok(Advance::Concluded(Completion::Cancelled)),
222        };
223        self.state.turns = self.state.turns.saturating_add(1);
224        self.state
225            .conversation
226            .push_assistant(report.assistant().to_vec());
227        self.state
228            .conversation
229            .push_tool_results(report.wave_results().to_vec());
230        // The provider's count for this call, against the model's
231        // window: a fact against a fact. It lands after the results the
232        // model reads next, by the door a steer takes.
233        if let Some(reminder) = report
234            .usage()
235            .and_then(|usage| self.state.gauge.observe(usage.input_tokens))
236        {
237            self.state.conversation.push_reminder(&reminder);
238        }
239        if report.calls_made() == 0 {
240            return Ok(Advance::Concluded(concluded(&report)?));
241        }
242        Ok(Advance::Turned)
243    }
244
245    /// The only exit. Both lines are written here, so no caller can end a
246    /// run by simply dropping it and leaving the ledger without a verdict.
247    ///
248    /// A cancelled run freezes inside the turn it interrupted and carries
249    /// that turn's stamp; any other ending samples the clock once, and
250    /// `run_frozen` follows one millisecond later because the two lines
251    /// record one event.
252    ///
253    /// # Errors
254    /// Propagates ledger failures and a clock that has run past `u64`.
255    pub fn freeze(
256        self,
257        ledger: &mut dyn Ledger,
258        handoff: &Handoff,
259        completion: Completion,
260        hooks: &mut RunHooks<'_>,
261    ) -> Result<Run<Frozen>, AxError> {
262        let t = match (&completion, self.state.last_turn_t) {
263            (Completion::Cancelled, Some(turn_t)) => turn_t,
264            _ => (hooks.now)()?,
265        };
266        self.plan.charter().close(ledger, handoff, &completion, t)?;
267        let turns = self.state.turns;
268        Ok(Run {
269            plan: self.plan,
270            state: Frozen {
271                completion,
272                turns,
273                conversation: self.state.conversation,
274            },
275        })
276    }
277
278    pub fn turns_taken(&self) -> u32 {
279        self.state.turns
280    }
281}
282
283#[cfg(test)]
284#[allow(
285    clippy::unwrap_used,
286    clippy::expect_used,
287    clippy::panic,
288    clippy::indexing_slicing,
289    reason = "test code"
290)]
291mod tests;