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;