Skip to main content

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}