Skip to main content

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}