Skip to main content

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(&started.task, &started.goal, rebuilt(&started));
98                at = record.seq();
99            }
100            EventKind::ModelReturned => {
101                // A turn that is already open means the ledger has a
102                // reply before the calls of the one before it were
103                // answered, which a run cannot produce. Reading it as
104                // the start of a new turn is what keeps a damaged
105                // history from being silently rewound.
106                commit(&mut conversation, open.take())?;
107                let returned = record.data().read::<ModelReturned>()?;
108                let assistant = content_from_message(&returned.message)?;
109                let mut exchange = Exchange::new();
110                exchange.push_assistant(assistant);
111                open = Some(Wave {
112                    exchange,
113                    expect: usize::try_from(returned.calls).unwrap_or(usize::MAX),
114                });
115                if returned.calls == 0 {
116                    commit(&mut conversation, open.take())?;
117                    at = record.seq();
118                }
119            }
120            EventKind::ToolResult => {
121                let result = record.data().read::<ToolResult>()?;
122                if let Some(wave) = open.as_mut() {
123                    wave.exchange.push_result(result_block(&result)?);
124                    if wave.exchange.results().len() >= wave.expect {
125                        commit(&mut conversation, open.take())?;
126                        at = record.seq();
127                    }
128                }
129            }
130            // The live run marks what it sent right after each assembly,
131            // and the shape line is the one every turn writes there
132            // (prompt_assembled is written once per run), so a steer
133            // after it lands where the live one landed.
134            EventKind::PromptShapeCompared => conversation.mark_sent(),
135            EventKind::SteerReceived => {
136                let steer = record.data().read::<SteerReceived>()?;
137                conversation.push_steer(&steer.source, &steer.text);
138            }
139            // Everything else is not the conversation: the segment
140            // hashes a call was assembled from, the accounting, the
141            // checks, the city's own lines. Listed rather than defaulted
142            // so a kind added to the vocabulary is a decision here - is
143            // this something the mother said or answered? - instead of a
144            // silence.
145            EventKind::CityInitialized
146            | EventKind::BuildingCreated
147            | EventKind::BuildingConfigured
148            | EventKind::BuildingRemoved
149            | EventKind::SessionOpened
150            | EventKind::RunForked
151            | EventKind::PromptAssembled
152            | EventKind::ModelCalled
153            | EventKind::ToolCalled
154            | EventKind::ResultOffloaded
155            | EventKind::GateChecked
156            | EventKind::GateDenied
157            | EventKind::CheckpointCommitted
158            | EventKind::HandoffWritten
159            | EventKind::CancelReceived
160            | EventKind::WatchdogFired
161            | EventKind::BudgetLimit
162            | EventKind::RunFrozen
163            | EventKind::LogTruncated
164            | EventKind::SignalEnqueued
165            | EventKind::SignalConsumed
166            | EventKind::DraftHeld
167            | EventKind::DraftResolved
168            | EventKind::GoalRegistered
169            | EventKind::GoalConflict
170            | EventKind::ArbitrationVerdict
171            | EventKind::RepairStarted
172            | EventKind::RepairReused
173            | EventKind::WorktreeOpened
174            | EventKind::PrOpened
175            | EventKind::PrMerged
176            | EventKind::PrRejected
177            | EventKind::RoadmapClaimed
178            | EventKind::RoadmapFinished
179            | EventKind::RoadmapReleased
180            | EventKind::RoadmapSplit
181            | EventKind::RoadmapBlocked
182            | EventKind::PursuitChanged
183            | EventKind::ApprovalRequested
184            | EventKind::ApprovalResolved
185            | EventKind::PolicyCreated
186            | EventKind::PolicyRevoked
187            | EventKind::TaintPromoted
188            | EventKind::CrossBuildingTransfer
189            | EventKind::CityHalted
190            | EventKind::BackpressureShed
191            | EventKind::DigestInvalidated
192            | EventKind::EndpointAttached
193            | EventKind::EndpointProbed
194            | EventKind::EndpointLost
195            | EventKind::ModelSelected
196            | EventKind::ProviderDegraded
197            | EventKind::LoginStarted
198            | EventKind::EvalRun
199            | EventKind::AssetArchived
200            | EventKind::CredentialLent
201            | EventKind::SecretCaptured
202            | EventKind::SecretEgressBlocked
203            | EventKind::FileDiscarded
204            | EventKind::DiscardRestored
205            | EventKind::AutonomyChanged
206            | EventKind::WentBack
207            | EventKind::FileRestored
208            | EventKind::GovernedDocumentWritten
209            | EventKind::SpineDocumentWritten
210            | EventKind::RulesChanged
211            | EventKind::ToolkitLinkOpened
212            | EventKind::EmbeddingCalled
213            | EventKind::RerankCalled
214            | EventKind::AdviserAsked
215            | EventKind::AdviserAnswered
216            | EventKind::AdviserFellBack
217            | EventKind::CacheRenewed
218            // A fork rebuilds a model run's conversation, and what a
219            // harness reported or answered is not one.
220            | EventKind::HarnessReported
221            | EventKind::HarnessAnswered
222            // The remote door is the city's, never a run's conversation.
223            | EventKind::RemoteOpened
224            | EventKind::RemoteClosed
225            | EventKind::DevicePaired
226            | EventKind::DeviceRevoked
227            | EventKind::RemoteSessionStarted
228            // A save and a proposal are the person's page and a file on
229            // disk, never a turn of the conversation.
230            | EventKind::DocumentWritten
231            | EventKind::ProposalOffered
232            | EventKind::ProposalDecided
233            | EventKind::ProposalWithdrawn => {}
234        }
235    }
236    // A wave the cut landed inside is dropped whole: the assistant
237    // message that asked for the calls and the answers it did get are
238    // one exchange, and half of one is a shape no provider accepts.
239    open.take();
240    Ok(Inherited {
241        messages: conversation.messages().to_vec(),
242        at,
243    })
244}
245
246/// How a branch writes the first message of a run it rebuilds: the way
247/// the mother wrote it, so a provider's cached prefix holds from the
248/// first message on (`crates/runtime/spec/Fork.lean` §8-58).
249///
250/// **`FromJob` is the one opening rewritten.** Its line says the task is
251/// in the JOB.md above, and the JOB.md it means is the mother's, in the
252/// run segment of her prefix; the branch's prefix carries its own brief,
253/// so the line copied would point the branch at a file it was never
254/// given. The rewrite costs the cache from the first message, and copying
255/// her job file into the branch's run segment instead would cost it from
256/// the system prompt (runtime D14).
257///
258/// A record from before `opening` was written is read the way it was
259/// read before.
260fn rebuilt(started: &RunStarted) -> Opening {
261    match started.opening {
262        Some(Opening::FromJob) => Opening::Inherited,
263        Some(spoken @ (Opening::Inherited | Opening::WithPerson)) => spoken,
264        None if started.job.is_some() => Opening::Inherited,
265        None => Opening::WithPerson,
266    }
267}
268
269/// One turn being folded: the assistant message and the results it is
270/// waiting for, held together because neither is a conversation without
271/// the other. The value is `Exchange`, the same one the turn layer
272/// collects live, so the two folds cannot drift.
273struct Wave {
274    exchange: Exchange,
275    expect: usize,
276}
277
278/// Folds one whole turn into the window, at the same boundary the live
279/// turn compacts at: the wave is complete here or it is dropped whole,
280/// so the compaction meets what the turn boundary met and answers the
281/// same bytes.
282fn commit(conversation: &mut Conversation, wave: Option<Wave>) -> Result<(), AxError> {
283    let Some(mut wave) = wave else {
284        return Ok(());
285    };
286    wave.exchange.compact()?;
287    conversation.push_assistant(wave.exchange.assistant().to_vec());
288    conversation.push_tool_results(wave.exchange.results().to_vec());
289    Ok(())
290}
291
292/// One tool result as the model reads it back, rebuilt from the record
293/// through the same printing the live wave used.
294///
295/// The pictures a tool produced are not rebuilt: the record carries the
296/// payload a tool answered with, and the block's `attachments` are the
297/// references that payload held, which the next request re-resolves from
298/// the content store. A fork without them loses what the next call would
299/// have fetched anyway.
300fn result_block(result: &ToolResult) -> Result<ContentBlock, AxError> {
301    let (payload, is_error) = match &result.answer {
302        ToolAnswer::Answered { result } => (result, false),
303        ToolAnswer::Failed { error } => (error, true),
304    };
305    Ok(ContentBlock::ToolResult {
306        tool_use_id: result.tool_use_id.clone(),
307        content: serde_json::to_string(payload).map_err(|err| {
308            AxError::failure(
309                AxCode::InvalidArgs,
310                "rebuild a tool result for a fork",
311                err.to_string(),
312            )
313            .with_recovery(
314                "report this against runtime::fork: a payload refused JSON after the \
315                 record that holds it was read",
316            )
317        })?,
318        is_error,
319        attachments: Vec::new(),
320    })
321}
322
323mod indexed;
324pub use indexed::inherited_indexed;
325
326#[cfg(test)]
327mod tests;
328
329#[cfg(test)]
330mod compaction_tests;
331
332#[cfg(test)]
333mod request_tests;