supercode_harness/turn_record.rs
1//! BP-7 (catalog §4a "Turn/step bracketing records", "Interrupt/abort with
2//! state preserved", "Auto-retry on transient provider errors"): the
3//! persisted per-round-trip marker log — cc's `turn_duration`/`api_retry`
4//! system records and cx's `turn_context`/`turn_aborted`/`responses_retry`
5//! rows, in one typed shape.
6//!
7//! **Where it lands.** `<session>.events.jsonl`, the sidecar-family member
8//! [`crate::store::SessionStore`] has always reserved and swept
9//! (archive/delete) but never had a writer for. So this is not a new store:
10//! it is the log that slot was cut for, filled in — beside the transcript,
11//! the reduction log, the usage log and the git-metadata record, exactly
12//! like every other family member (§1.13's "typed session data, never a
13//! lossy display-only channel").
14//!
15//! **Relationship to the usage log.** [`crate::usage_log::UsageRecord`] is
16//! the ACCOUNTING projection: one row per round-trip, aggregatable by a
17//! cost dashboard. This is the BRACKETING projection: what the request was
18//! built over (`Context`), what it cost (`Usage`), how the round-trip ended
19//! (`Finish`), and the two things that happen *between* round-trips
20//! (`Retry`, `Aborted`). They are written from the same points in
21//! `Agent::run_loop` and never disagree; keeping them separate keeps a
22//! usage-log reader from having to skip four record kinds it does not care
23//! about.
24
25use serde::{Deserialize, Serialize};
26
27/// Why a model round-trip (or a whole `send` loop) ended.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum FinishReason {
31 /// The assistant asked for tool calls; the loop continues.
32 ToolCalls,
33 /// The assistant produced a final answer and the loop returned.
34 EndTurn,
35 /// `core.max_iterations` was exhausted.
36 MaxIterations,
37 /// `core.max_total_output_tokens` was reached.
38 OutputTokenBudget,
39 /// `core.max_budget_usd` was reached.
40 SpendBudget,
41 /// `core.max_steps` was reached.
42 StepBudget,
43}
44
45impl FinishReason {
46 /// The wire spelling, for a reader that renders these without serde.
47 pub fn label(self) -> &'static str {
48 match self {
49 FinishReason::ToolCalls => "tool_calls",
50 FinishReason::EndTurn => "end_turn",
51 FinishReason::MaxIterations => "max_iterations",
52 FinishReason::OutputTokenBudget => "output_token_budget",
53 FinishReason::SpendBudget => "spend_budget",
54 FinishReason::StepBudget => "step_budget",
55 }
56 }
57}
58
59/// One bracketing marker. The `marker` tag is the record's kind.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(tag = "marker", rename_all = "snake_case")]
62pub enum TurnMarker {
63 /// Opens a round-trip: the shape of the context the request was built
64 /// over, captured BEFORE the request is issued (so it survives a
65 /// request that never returns).
66 Context {
67 /// Messages in the request.
68 messages: usize,
69 /// Tool schemas advertised on the request.
70 tools: usize,
71 /// Estimated prompt tokens (`crate::tokens`' own estimator — the
72 /// same one the context guard uses, so the two never disagree).
73 estimated_tokens: u64,
74 },
75 /// The round-trip's provider-reported token accounting, plus its dollar
76 /// cost when the model is priceable ([`crate::pricing`]).
77 Usage {
78 /// Input tokens.
79 prompt_tokens: u64,
80 /// Output tokens.
81 completion_tokens: u64,
82 /// Provider-reported total.
83 total_tokens: u64,
84 /// Prompt tokens served from the provider's cache, if reported.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 cached_tokens: Option<u64>,
87 /// Dollar cost, `None` when this build cannot price the model.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 cost_usd: Option<f64>,
90 },
91 /// Closes a round-trip (or the loop).
92 Finish {
93 /// Why it ended.
94 reason: FinishReason,
95 },
96 /// A transient provider failure was retried with backoff. Written from
97 /// the notices the transport's own retry loop records, so a retried
98 /// request is visible in the log instead of being invisible the way the
99 /// ledger's `auto-retry-on-transient-provider-errors` row described.
100 Retry {
101 /// 0-based attempt index that FAILED (attempt 0 is the first try).
102 attempt: u32,
103 /// Backoff slept before the next attempt, milliseconds.
104 delay_ms: u64,
105 /// One-line reason (HTTP status or transport error).
106 reason: String,
107 },
108 /// The turn was interrupted (Ctrl-C / a cancelled `send` future). The
109 /// partial work already appended to the transcript stands; this marker
110 /// is what makes the interruption a FACT on reload rather than
111 /// something a reader has to infer from a dangling tool call.
112 Aborted {
113 /// Where the interruption came from (`"ctrl_c"`, `"cancelled"`, …).
114 source: String,
115 /// Messages in the agent's history at the moment of the abort.
116 messages: usize,
117 },
118 /// Reasoning effort changed mid-session (`/effort`), the extended-
119 /// thinking analog of the `model_change` log.
120 Effort {
121 /// Effort before the change (`None` = thinking off).
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 from: Option<String>,
124 /// Effort after the change (`None` = thinking off).
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 to: Option<String>,
127 },
128 /// The session's persistent objective was set, changed, or cleared
129 /// (`/goal`). The goal itself lives in `<session>.goal.json`; this is
130 /// the audit trail of when it moved.
131 Goal {
132 /// The objective after the change; empty means cleared.
133 objective: String,
134 },
135}
136
137/// One marker with the per-round-trip context every marker shares.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct TurnRecord {
140 /// 0-based index of the model round-trip this record brackets — the
141 /// same counter [`crate::usage_log::UsageRecord::turn`] uses, so the
142 /// two logs join on it.
143 pub turn: usize,
144 /// The model in effect when the marker was written.
145 pub model: String,
146 /// Unix-ms wall-clock time.
147 pub timestamp_ms: i64,
148 /// The marker itself.
149 #[serde(flatten)]
150 pub marker: TurnMarker,
151}
152
153impl TurnRecord {
154 /// Build a record for `marker`.
155 pub fn new(turn: usize, model: &str, timestamp_ms: i64, marker: TurnMarker) -> TurnRecord {
156 TurnRecord {
157 turn,
158 model: model.to_string(),
159 timestamp_ms,
160 marker,
161 }
162 }
163}
164
165/// Serialize `records` as JSONL — the same shape every other append-log in
166/// this crate uses.
167pub fn to_jsonl(records: &[TurnRecord]) -> crate::Result<String> {
168 let mut out = String::new();
169 for r in records {
170 out.push_str(&serde_json::to_string(r).map_err(crate::Error::Decode)?);
171 out.push('\n');
172 }
173 Ok(out)
174}
175
176/// Parse a JSONL marker log back into records — the exact inverse of
177/// [`to_jsonl`]. Blank lines are skipped; a malformed line is a hard error
178/// (this is an audit log, so a corrupt record should be visible, never
179/// silently dropped — same posture as [`crate::usage_log::from_jsonl`]).
180pub fn from_jsonl(text: &str) -> crate::Result<Vec<TurnRecord>> {
181 let mut out = Vec::new();
182 for line in text.lines() {
183 let line = line.trim();
184 if line.is_empty() {
185 continue;
186 }
187 out.push(serde_json::from_str(line).map_err(crate::Error::Decode)?);
188 }
189 Ok(out)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn sample() -> Vec<TurnRecord> {
197 vec![
198 TurnRecord::new(
199 0,
200 "anthropic/claude-opus-4-8",
201 1_700_000_000_000,
202 TurnMarker::Context {
203 messages: 3,
204 tools: 4,
205 estimated_tokens: 1200,
206 },
207 ),
208 TurnRecord::new(
209 0,
210 "anthropic/claude-opus-4-8",
211 1_700_000_001_000,
212 TurnMarker::Usage {
213 prompt_tokens: 1000,
214 completion_tokens: 50,
215 total_tokens: 1050,
216 cached_tokens: Some(200),
217 cost_usd: Some(0.01875),
218 },
219 ),
220 TurnRecord::new(
221 0,
222 "anthropic/claude-opus-4-8",
223 1_700_000_001_100,
224 TurnMarker::Finish {
225 reason: FinishReason::EndTurn,
226 },
227 ),
228 TurnRecord::new(
229 1,
230 "anthropic/claude-opus-4-8",
231 1_700_000_002_000,
232 TurnMarker::Retry {
233 attempt: 0,
234 delay_ms: 500,
235 reason: "provider status 503".to_string(),
236 },
237 ),
238 TurnRecord::new(
239 1,
240 "anthropic/claude-opus-4-8",
241 1_700_000_003_000,
242 TurnMarker::Aborted {
243 source: "ctrl_c".to_string(),
244 messages: 7,
245 },
246 ),
247 TurnRecord::new(
248 2,
249 "anthropic/claude-opus-4-8",
250 1_700_000_004_000,
251 TurnMarker::Effort {
252 from: Some("medium".to_string()),
253 to: None,
254 },
255 ),
256 TurnRecord::new(
257 2,
258 "anthropic/claude-opus-4-8",
259 1_700_000_005_000,
260 TurnMarker::Goal {
261 objective: "ship BP-7".to_string(),
262 },
263 ),
264 ]
265 }
266
267 #[test]
268 fn jsonl_round_trip_is_lossless_for_every_marker_kind() {
269 let records = sample();
270 let jsonl = to_jsonl(&records).unwrap();
271 assert_eq!(jsonl.lines().count(), records.len());
272 assert_eq!(from_jsonl(&jsonl).unwrap(), records);
273 }
274
275 #[test]
276 fn the_marker_tag_names_the_kind_on_the_wire() {
277 let jsonl = to_jsonl(&sample()).unwrap();
278 let kinds: Vec<String> = jsonl
279 .lines()
280 .map(|l| {
281 serde_json::from_str::<serde_json::Value>(l).unwrap()["marker"]
282 .as_str()
283 .unwrap()
284 .to_string()
285 })
286 .collect();
287 assert_eq!(
288 kinds,
289 vec!["context", "usage", "finish", "retry", "aborted", "effort", "goal"]
290 );
291 }
292
293 #[test]
294 fn a_record_carries_the_turn_index_the_usage_log_joins_on() {
295 let v: serde_json::Value =
296 serde_json::from_str(to_jsonl(&sample()).unwrap().lines().next().unwrap()).unwrap();
297 assert_eq!(v["turn"], 0);
298 assert_eq!(v["model"], "anthropic/claude-opus-4-8");
299 assert_eq!(v["messages"], 3);
300 }
301
302 #[test]
303 fn empty_and_blank_input_round_trip_to_empty() {
304 assert_eq!(to_jsonl(&[]).unwrap(), "");
305 assert_eq!(from_jsonl("\n\n").unwrap(), Vec::<TurnRecord>::new());
306 }
307
308 #[test]
309 fn a_malformed_line_is_an_error_not_a_silent_drop() {
310 assert!(from_jsonl("{\"turn\":0}\n").is_err());
311 }
312
313 #[test]
314 fn finish_reason_labels_match_the_wire_spelling() {
315 for reason in [
316 FinishReason::ToolCalls,
317 FinishReason::EndTurn,
318 FinishReason::MaxIterations,
319 FinishReason::OutputTokenBudget,
320 FinishReason::SpendBudget,
321 FinishReason::StepBudget,
322 ] {
323 let v = serde_json::to_value(reason).unwrap();
324 assert_eq!(v.as_str(), Some(reason.label()));
325 }
326 }
327}