Skip to main content

oino_session/manager/
repair.rs

1//! D-DISPATCH / D-REPAIR (core spec §2.1, WP-C2): the tool-dispatch record and the
2//! interrupted-turn repair on resume.
3//!
4//! The dispatch record is one `custom` entry per side-effect tool call, appended before
5//! the tool body runs, so a crash between dispatch and result leaves an attributable
6//! record. Repair is the resume layer's job and appends closers only: a torn physical
7//! tail is terminated with `"\n"` (the reference repair, `session-manager.js:365-367`) so the
8//! fragment stays a permanently skipped line, and every dangling tool call of the
9//! trailing assistant message is closed with an unknown-outcome error toolResult —
10//! exactly a `toolResult` message any v3-format reader understands. Existing bytes are
11//! never rewritten or truncated, and open calls are never auto-retried.
12
13use std::collections::HashSet;
14
15use oino_ai::{
16    AgentMessage, AssistantContent, JsonObject, ToolCall, ToolResultMessage, UserContent,
17};
18use serde_json::json;
19
20use super::{SessionManager, files};
21use crate::entry::EntryId;
22use crate::error::SessionError;
23use crate::time::now_ms;
24
25/// `customType` of the dispatch record (D-DISPATCH).
26pub const TOOL_DISPATCH_TYPE: &str = "oino.tool-dispatch";
27
28/// `customType` of the repair record (D-REPAIR).
29pub const RESUME_REPAIR_TYPE: &str = "oino.resume-repair";
30
31/// Text closing a dangling tool call (D-REPAIR): the outcome is unknown, so the model
32/// must verify before retrying — open calls are never auto-retried.
33pub const INTERRUPTED_TOOL_TEXT: &str = "Tool execution was interrupted before completion; the outcome is unknown. The effect may or may not have happened. Do not retry blindly: verify the result first, and ask the user if verification is not possible.";
34
35/// Outcome of [`SessionManager::repair_interrupted_turn`].
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct RepairReport {
38    /// Ids of the closed dangling tool calls, in append order.
39    pub closed_tool_calls: Vec<String>,
40    /// Whether a torn tail line was terminated with `"\n"`.
41    pub torn_tail: bool,
42}
43
44impl SessionManager {
45    /// Appends the [`TOOL_DISPATCH_TYPE`] record of a tool call (D-DISPATCH): a `custom`
46    /// entry naming the call before its body runs, so resume repair can attribute an
47    /// interrupted effect. `custom` entries never project into context
48    /// (`session-manager.js:164-181`) — inert to v3-format readers.
49    ///
50    /// # Errors
51    ///
52    /// I/O when persisting.
53    pub fn append_tool_dispatch(
54        &mut self,
55        tool_call_id: &str,
56        tool_name: &str,
57        input: &JsonObject,
58    ) -> Result<EntryId, SessionError> {
59        self.append_custom_entry(
60            TOOL_DISPATCH_TYPE,
61            Some(json!({
62                "toolCallId": tool_call_id,
63                "toolName": tool_name,
64                "input": input,
65                "readOnly": false,
66            })),
67        )
68    }
69
70    /// Repairs an interrupted turn at the leaf (D-REPAIR). Call once, under write
71    /// ownership, before the session is used; appends only, never rewrites or truncates:
72    ///
73    /// 1. a torn last line (non-empty, not valid JSON) is terminated with `"\n"` so it
74    ///    stays a permanently skipped line — appended before any closer, so a closer
75    ///    never concatenates onto the fragment;
76    /// 2. every `toolCall` of the trailing assistant message without a matching
77    ///    `toolResult` in the trailing run is closed with an unknown-outcome error
78    ///    toolResult;
79    /// 3. one [`RESUME_REPAIR_TYPE`] record and a `sync()` follow when anything was
80    ///    repaired.
81    ///
82    /// Repair is idempotent: after the first repair the trailing run is balanced and the
83    /// dangling scan finds nothing. In-memory sessions and sessions whose file was never
84    /// created have nothing to repair and are left untouched.
85    ///
86    /// # Errors
87    ///
88    /// I/O when reading or appending (fail-closed: the caller must not use the session).
89    pub fn repair_interrupted_turn(&mut self) -> Result<RepairReport, SessionError> {
90        let mut report = RepairReport::default();
91        let Some(path) = self
92            .session_file
93            .clone()
94            .filter(|_| self.persist && self.flushed)
95        else {
96            return Ok(report);
97        };
98        if torn_tail(&files::read_to_string(&path)?) {
99            // `write_line` appends exactly `"\n"` for an empty line.
100            self.write_appended(&path, "")?;
101            report.torn_tail = true;
102        }
103        for call in self.dangling_tool_calls() {
104            self.append_message(AgentMessage::ToolResult(ToolResultMessage {
105                tool_call_id: call.id.clone(),
106                tool_name: call.name.clone(),
107                content: vec![UserContent::text(INTERRUPTED_TOOL_TEXT)],
108                details: None,
109                usage: None,
110                nested_calls: None,
111                is_error: true,
112                timestamp: now_ms(),
113                duration_ms: None,
114            }))?;
115            report.closed_tool_calls.push(call.id);
116        }
117        if report.torn_tail || !report.closed_tool_calls.is_empty() {
118            self.append_custom_entry(
119                RESUME_REPAIR_TYPE,
120                Some(json!({
121                    "closedToolCalls": report.closed_tool_calls,
122                    "tornTail": report.torn_tail,
123                    "at": now_ms(),
124                })),
125            )?;
126            self.sync()?;
127        }
128        Ok(report)
129    }
130
131    /// Dangling tool calls of the trailing run (D-REPAIR scan): from the end of the leaf
132    /// projection, the trailing `toolResult` messages and the assistant message that
133    /// issued them — every `toolCall.id` there without a matching `toolResult` in the
134    /// trailing run.
135    fn dangling_tool_calls(&self) -> Vec<ToolCall> {
136        let messages = &self.build_session_projection().messages;
137        let mut start = messages.len();
138        while start > 0 && matches!(messages[start - 1], AgentMessage::ToolResult(_)) {
139            start -= 1;
140        }
141        let Some(AgentMessage::Assistant(assistant)) =
142            start.checked_sub(1).and_then(|index| messages.get(index))
143        else {
144            return Vec::new();
145        };
146        let answered: HashSet<&str> = messages[start..]
147            .iter()
148            .filter_map(|message| match message {
149                AgentMessage::ToolResult(result) => Some(result.tool_call_id.as_str()),
150                _ => None,
151            })
152            .collect();
153        assistant
154            .content
155            .iter()
156            .filter_map(|block| match block {
157                AssistantContent::ToolCall(call) => {
158                    (!answered.contains(call.id.as_str())).then(|| call.clone())
159                }
160                _ => None,
161            })
162            .collect()
163    }
164}
165
166/// Whether the file's last line is a torn fragment: non-empty and not valid JSON.
167fn torn_tail(text: &str) -> bool {
168    match text.split('\n').next_back() {
169        Some(last) => !last.is_empty() && serde_json::from_str::<serde_json::Value>(last).is_err(),
170        None => false,
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn detects_torn_tail_only_for_invalid_fragments() {
180        // Ends with newline: the trailing fragment is empty.
181        assert!(!torn_tail("{}\n"));
182        // Valid JSON last line without newline: readable, nothing to repair.
183        assert!(!torn_tail("{}\n{\"id\":\"a\"}"));
184        // Torn write: non-empty, invalid JSON.
185        assert!(torn_tail("{}\n{\"type\":\"mess"));
186        assert!(torn_tail("{\"type\":\"mess"));
187        assert!(!torn_tail(""));
188    }
189}