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}