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