runtime/replay/resume.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//! Crash recovery over verified records: which tool calls have no
7//! outcome, and the line that closes one.
8//!
9//! A `tool_called` with no later `tool_result` in the same run is a
10//! call whose outcome nobody knows. Detection and repair sit in one
11//! file because they are one rule read twice — what counts as dangling
12//! decides what the closing line must say.
13
14use std::collections::BTreeMap;
15
16use kernel::event::record::{ToolAnswer, ToolCalled, ToolResult};
17use kernel::{AxCode, AxError, EventDraft, EventKind, EventRecord, Payload, TimeMs};
18
19/// The crash-recovery detection half of resume, fed one verified record
20/// at a time so it rides the pass that verifies the chain: a
21/// `tool_called` with no later `tool_result` in the same run is a call
22/// whose outcome is unknown, and so is a call its own run followed with
23/// another call before any result.
24///
25/// What it holds is the calls still open, one per run, and the calls
26/// already found dangling, never the ledger.
27#[derive(Debug, Default)]
28pub struct DanglingCalls {
29 open: BTreeMap<[u8; 16], EventRecord>,
30 dangling: Vec<EventRecord>,
31}
32
33impl DanglingCalls {
34 /// Reads one record, in ledger order.
35 pub fn observe(&mut self, record: &EventRecord) {
36 let key = *record.run().as_bytes();
37 #[expect(
38 clippy::wildcard_enum_match_arm,
39 reason = "a call and its result open and close a pairing; no other kind opens or closes one"
40 )]
41 match record.kind() {
42 EventKind::ToolCalled => {
43 if let Some(older) = self.open.insert(key, record.clone()) {
44 self.dangling.push(older);
45 }
46 }
47 EventKind::ToolResult => {
48 self.open.remove(&key);
49 }
50 _ => {}
51 }
52 }
53
54 /// The calls with no outcome, ascending by seq, once every record
55 /// has been observed.
56 pub fn into_calls(self) -> Vec<EventRecord> {
57 let mut calls = self.dangling;
58 calls.extend(self.open.into_values());
59 calls.sort_by_key(|call| call.seq().value());
60 calls
61 }
62}
63
64/// The repair half: the `tool_result` draft that closes a dangling call
65/// with `E_TOOL_OUTCOME_UNKNOWN`. The resume path appends it before any
66/// new turn — the account never shows a call without an outcome.
67pub fn outcome_unknown_draft(call: &EventRecord, t: TimeMs) -> Result<EventDraft, AxError> {
68 if call.kind() != EventKind::ToolCalled {
69 return Err(AxError::failure(
70 AxCode::InvalidArgs,
71 "draft unknown outcome",
72 "record is not a tool_called",
73 )
74 .with_recovery(
75 "pass the `tool_called` record that has no `tool_result` after it; only a \
76 call can be closed as an unknown outcome",
77 ));
78 }
79 // Read through the one struct `tool_called` is written from, so a
80 // call this build cannot read is refused rather than closed against
81 // a name that names nothing and paired with an id no line answers.
82 let called = call.data().read::<ToolCalled>()?;
83 let error = AxError::failure(
84 AxCode::ToolOutcomeUnknown,
85 "recover tool outcome",
86 format!("{name} ({id})", name = called.name.as_str(), id = called.id),
87 )
88 .with_recovery(
89 "the call may or may not have taken effect; verify the external state before retrying",
90 );
91 let answer = ToolAnswer::Failed {
92 error: Payload::of(&error)?,
93 };
94 let data = Payload::of(&ToolResult {
95 tool_use_id: called.id.clone(),
96 name: called.name.clone(),
97 answer,
98 // The call's own end was never seen: this answer is the city's
99 // verdict at reopening, so no duration belongs to it.
100 took_us: None,
101 })?;
102 Ok(EventDraft {
103 run: call.run(),
104 t,
105 who: call.who().to_owned(),
106 addr: None,
107 kind: EventKind::ToolResult,
108 data,
109 ig: false,
110 })
111}