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(
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;