runtime/fork.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//! Fork: a new Run whose in-window history is a byte-identical prefix of
7//! the mother sequence. Forking is not
8//! resurrection: the mother's frozen state never changes, and the new
9//! RunId arrives from the caller — this module is pure.
10//! `crates/runtime/spec/Fork.lean` proves what a fork must hold (§8-2):
11//! the prefix is the mother's first lines, and the cut never splits a wave.
12//!
13//! [`prefix`] consumes [`VerifiedLedger`], so replay and fork share one
14//! rebuilder; a branch's conversation is rebuilt through the ledger
15//! index by [`inherited_indexed`], because the ledger's only writer asks
16//! for it and verified the ledger when it opened it.
17
18use kernel::event::record::{ModelReturned, RunStarted, SteerReceived, ToolAnswer, ToolResult};
19use kernel::model::content_from_message;
20use kernel::{AxCode, AxError, ChatMessage, ContentBlock, EventKind, EventRecord, RunId, Seq};
21
22use crate::compaction::Exchange;
23use crate::conversation::{Conversation, Opening};
24use crate::replay::VerifiedLedger;
25
26mod lineage;
27
28pub use lineage::fork_draft;
29use lineage::forked_from;
30
31/// The fork prefix: raw lines `0..=at_seq`, byte-exact. Past the tail is
32/// `E_INVALID_ARGS`, never a silent clamp.
33pub fn prefix(mother: &VerifiedLedger, at_seq: Seq) -> Result<Vec<Vec<u8>>, AxError> {
34 let refuse = || {
35 AxError::failure(
36 AxCode::InvalidArgs,
37 "fork",
38 format!("at_seq {}", at_seq.value()),
39 )
40 .with_recovery(match mother.tail_seq() {
41 Some(tail) => format!("the mother sequence ends at seq {}", tail.value()),
42 None => "the mother sequence is empty".to_string(),
43 })
44 };
45 let tail = mother.tail_seq().ok_or_else(refuse)?;
46 if at_seq > tail {
47 return Err(refuse());
48 }
49 let index = usize::try_from(at_seq.value()).map_err(|_| refuse())?;
50 let end = index.checked_add(1).ok_or_else(refuse)?;
51 mother
52 .raw_lines()
53 .get(..end)
54 .map(<[Vec<u8>]>::to_vec)
55 .ok_or_else(refuse)
56}
57
58/// What a new session inherits from the one it branched off: the
59/// conversation the mother had exchanged by a safe point.
60#[derive(Debug, Clone, PartialEq)]
61pub struct Inherited {
62 /// The messages the forked run's window opens with, in the order the
63 /// mother exchanged them.
64 pub messages: Vec<ChatMessage>,
65 /// The line those messages were rebuilt through. Equal to the
66 /// `at_seq` asked for when that line was a safe point, and the
67 /// nearest earlier one when it was not: a wave of tool calls is
68 /// several lines and only its end is a place a conversation can be
69 /// cut, because a provider refuses an assistant message whose tool
70 /// uses nothing answers.
71 pub at: Seq,
72}
73
74/// The refusal for a run whose opening line this history does not hold.
75fn no_start(owner: RunId) -> AxError {
76 AxError::failure(
77 AxCode::InvalidArgs,
78 "fork",
79 format!("{owner} has no run_started in this ledger"),
80 )
81 .with_recovery("name a run this history holds, or start a session instead")
82}
83
84/// Folds one run's own records, from its `run_started` through the cut,
85/// into the conversation it sent. The one fold both doors share, so the
86/// verified door and the indexed one cut at the same line.
87fn fold_run<'a>(
88 mut conversation: Conversation,
89 records: impl Iterator<Item = &'a EventRecord>,
90) -> Result<Inherited, AxError> {
91 let mut at = Seq::FIRST;
92 let mut open: Option<Wave> = None;
93 for record in records {
94 match record.kind() {
95 EventKind::RunStarted => {
96 let started = record.data().read::<RunStarted>()?;
97 conversation.push_task_lines(
98 &started.task,
99 &started.goal,
100 rebuilt(&started),
101 &handed_down_by(&started),
102 );
103 at = record.seq();
104 }
105 EventKind::ModelReturned => {
106 // A turn that is already open means the ledger has a
107 // reply before the calls of the one before it were
108 // answered, which a run cannot produce. Reading it as
109 // the start of a new turn is what keeps a damaged
110 // history from being silently rewound.
111 commit(&mut conversation, open.take())?;
112 let returned = record.data().read::<ModelReturned>()?;
113 let assistant = content_from_message(&returned.message)?;
114 let mut exchange = Exchange::new();
115 exchange.push_assistant(assistant);
116 open = Some(Wave {
117 exchange,
118 expect: usize::try_from(returned.calls).unwrap_or(usize::MAX),
119 });
120 if returned.calls == 0 {
121 commit(&mut conversation, open.take())?;
122 at = record.seq();
123 }
124 }
125 EventKind::ToolResult => {
126 let result = record.data().read::<ToolResult>()?;
127 if let Some(wave) = open.as_mut() {
128 wave.exchange.push_result(result_block(&result)?);
129 if wave.exchange.results().len() >= wave.expect {
130 commit(&mut conversation, open.take())?;
131 at = record.seq();
132 }
133 }
134 }
135 // The live run marks what it sent right after each assembly,
136 // and the shape line is the one every turn writes there
137 // (prompt_assembled is written once per run), so a steer
138 // after it lands where the live one landed.
139 EventKind::PromptShapeCompared => conversation.mark_sent(),
140 EventKind::SteerReceived => {
141 let steer = record.data().read::<SteerReceived>()?;
142 conversation.push_steer(
143 &crate::conversation::Speaker::from_recorded(&steer.source)?,
144 &steer.text,
145 );
146 }
147 // Everything else is not the conversation: the segment
148 // hashes a call was assembled from, the accounting, the
149 // checks, the city's own lines. Listed rather than defaulted
150 // so a kind added to the vocabulary is a decision here - is
151 // this something the mother said or answered? - instead of a
152 // silence.
153 EventKind::CityInitialized
154 | EventKind::BuildingCreated
155 | EventKind::BuildingConfigured
156 | EventKind::BuildingRemoved
157 | EventKind::SessionOpened
158 | EventKind::RunForked
159 | EventKind::PromptAssembled
160 | EventKind::ModelCalled
161 | EventKind::ToolCalled
162 | EventKind::ResultOffloaded
163 | EventKind::GateChecked
164 | EventKind::GateDenied
165 | EventKind::CheckpointCommitted
166 | EventKind::HandoffWritten
167 | EventKind::CancelReceived
168 | EventKind::WatchdogFired
169 | EventKind::BudgetLimit
170 | EventKind::RunFrozen
171 | EventKind::LogTruncated
172 | EventKind::SignalEnqueued
173 | EventKind::SignalLanded
174 | EventKind::SignalConsumed
175 | EventKind::DraftHeld
176 | EventKind::DraftResolved
177 | EventKind::GoalRegistered
178 | EventKind::GoalConflict
179 | EventKind::ArbitrationVerdict
180 | EventKind::RepairStarted
181 | EventKind::RepairReused
182 | EventKind::WorktreeOpened
183 | EventKind::PrOpened
184 | EventKind::PrMerged
185 | EventKind::PrRejected
186 | EventKind::RoadmapClaimed
187 | EventKind::RoadmapFinished
188 | EventKind::RoadmapReleased
189 | EventKind::RoadmapSplit
190 | EventKind::RoadmapBlocked
191 | EventKind::PursuitChanged
192 | EventKind::ApprovalRequested
193 | EventKind::ApprovalResolved
194 | EventKind::PolicyCreated
195 | EventKind::PolicyRevoked
196 | EventKind::TaintPromoted
197 | EventKind::CrossBuildingTransfer
198 | EventKind::CityHalted
199 | EventKind::BackpressureShed
200 | EventKind::DigestInvalidated
201 | EventKind::EndpointAttached
202 | EventKind::EndpointProbed
203 | EventKind::EndpointLost
204 | EventKind::ModelSelected
205 | EventKind::ProviderDegraded
206 | EventKind::LoginStarted
207 | EventKind::EvalRun
208 | EventKind::AssetArchived
209 | EventKind::CredentialLent
210 | EventKind::SecretCaptured
211 | EventKind::SecretEgressBlocked
212 | EventKind::FileDiscarded
213 | EventKind::DiscardRestored
214 | EventKind::AutonomyChanged
215 | EventKind::WentBack
216 | EventKind::FileRestored
217 | EventKind::GovernedDocumentWritten
218 | EventKind::SpineDocumentWritten
219 | EventKind::RulesChanged
220 | EventKind::ToolkitLinkOpened
221 | EventKind::EmbeddingCalled
222 | EventKind::RerankCalled
223 | EventKind::AdviserAsked
224 | EventKind::AdviserAnswered
225 | EventKind::AdviserFellBack
226 | EventKind::CacheRenewed
227 | EventKind::PrefixHeld
228 // A fork rebuilds a model run's conversation, and what a
229 // harness reported or answered is not one.
230 | EventKind::HarnessReported
231 | EventKind::HarnessAnswered
232 // The remote door is the city's, never a run's conversation.
233 | EventKind::RemoteOpened
234 | EventKind::RemoteClosed
235 | EventKind::DevicePaired
236 | EventKind::DeviceRevoked
237 | EventKind::RemoteSessionStarted
238 // A save and a proposal are the person's page and a file on
239 // disk, never a turn of the conversation.
240 | EventKind::DocumentWritten
241 | EventKind::ProposalOffered
242 | EventKind::ProposalDecided
243 | EventKind::ProposalWithdrawn
244 // The sentence a policy change appends is not in the
245 // conversation yet: no run reads the line today (kernel D21).
246 | EventKind::RunPolicyChanged
247 // A session's name, a skill's audit and who shelved it are
248 // the person's page.
249 | EventKind::SessionNamed
250 | EventKind::SkillAudited
251 | EventKind::SkillShelved
252 // How a reply wait ended reaches the conversation as the
253 // `steer_received` line the turn writes when the run goes on
254 // (collab D9), so it is rebuilt above; these two lines are
255 // the wait's own accounting (kernel D32).
256 | EventKind::SignalWaitStarted
257 | EventKind::SignalWaitEnded => {}
258 }
259 }
260 // A wave the cut landed inside is dropped whole: the assistant
261 // message that asked for the calls and the answers it did get are
262 // one exchange, and half of one is a shape no provider accepts.
263 open.take();
264 Ok(Inherited {
265 messages: conversation.messages().to_vec(),
266 at,
267 })
268}
269
270/// Who handed the rebuilt run its work, in the words its opening was
271/// written with live (`kernel::event::Who::handed_down_by`). A record
272/// from before `dispatched_by` was written names no dispatcher, and is
273/// never read as the User's.
274fn handed_down_by(started: &RunStarted) -> String {
275 started.dispatched_by.as_ref().map_or_else(
276 || UNRECORDED_DISPATCHER.to_owned(),
277 |who| who.handed_down_by(started.predecessor, started.parent),
278 )
279}
280
281/// What a rebuilt opening names when its `run_started` predates
282/// `dispatched_by`.
283const UNRECORDED_DISPATCHER: &str = "an unrecorded dispatcher";
284
285/// How a branch writes the first message of a run it rebuilds: the way
286/// the mother wrote it, so a provider's cached prefix holds from the
287/// first message on (`crates/runtime/spec/Fork.lean` §8-58).
288///
289/// **`FromJob` is the one opening rewritten.** Its line says the task is
290/// in the JOB.md above, and the JOB.md it means is the mother's, in the
291/// run segment of her prefix; the branch's prefix carries its own brief,
292/// so the line copied would point the branch at a file it was never
293/// given. The rewrite costs the cache from the first message, and copying
294/// her job file into the branch's run segment instead would cost it from
295/// the system prompt (runtime D14).
296///
297/// A record from before `opening` was written is read the way it was
298/// read before.
299fn rebuilt(started: &RunStarted) -> Opening {
300 match started.opening {
301 Some(Opening::FromJob) => Opening::Inherited,
302 Some(spoken @ (Opening::Inherited | Opening::WithPerson)) => spoken,
303 None if started.job.is_some() => Opening::Inherited,
304 None => Opening::WithPerson,
305 }
306}
307
308/// One turn being folded: the assistant message and the results it is
309/// waiting for, held together because neither is a conversation without
310/// the other. The value is `Exchange`, the same one the turn layer
311/// collects live, so the two folds cannot drift.
312struct Wave {
313 exchange: Exchange,
314 expect: usize,
315}
316
317/// Folds one whole turn into the window, at the same boundary the live
318/// turn compacts at: the wave is complete here or it is dropped whole,
319/// so the compaction meets what the turn boundary met and answers the
320/// same bytes.
321fn commit(conversation: &mut Conversation, wave: Option<Wave>) -> Result<(), AxError> {
322 let Some(mut wave) = wave else {
323 return Ok(());
324 };
325 wave.exchange.compact()?;
326 conversation.push_assistant(wave.exchange.assistant().to_vec());
327 conversation.push_tool_results(wave.exchange.results().to_vec());
328 Ok(())
329}
330
331/// One tool result as the model reads it back, rebuilt from the record
332/// through the same printing the live wave used.
333///
334/// The pictures a tool produced are not rebuilt: the record carries the
335/// payload a tool answered with, and the block's `attachments` are the
336/// references that payload held, which the next request re-resolves from
337/// the content store. A fork without them loses what the next call would
338/// have fetched anyway.
339fn result_block(result: &ToolResult) -> Result<ContentBlock, AxError> {
340 let (payload, is_error) = match &result.answer {
341 ToolAnswer::Answered { result } => (result, false),
342 ToolAnswer::Failed { error } => (error, true),
343 };
344 Ok(ContentBlock::ToolResult {
345 tool_use_id: result.tool_use_id.clone(),
346 content: serde_json::to_string(payload).map_err(|err| {
347 AxError::failure(
348 AxCode::InvalidArgs,
349 "rebuild a tool result for a fork",
350 err.to_string(),
351 )
352 .with_recovery(
353 "report this against runtime::fork: a payload refused JSON after the \
354 record that holds it was read",
355 )
356 })?,
357 is_error,
358 attachments: Vec::new(),
359 })
360}
361
362mod indexed;
363pub use indexed::inherited_indexed;
364
365#[cfg(test)]
366mod tests;
367
368#[cfg(test)]
369mod compaction_tests;
370
371#[cfg(test)]
372mod request_tests;