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