Skip to main content

forge_primitives/
handoff.rs

1//! The handoff record: what one session swap carries, and the numbers it
2//! leaves behind for tuning the trigger.
3
4use serde::{Deserialize, Serialize};
5
6use crate::session_slot::SessionSlot;
7
8/// Why a session handed over.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HandoffReason {
12    /// The seat's context fill crossed its configured share of the window.
13    Fill,
14    /// A compaction fired before a turn boundary could carry the handoff.
15    Compaction,
16    /// A person asked for one.
17    #[default]
18    Manual,
19}
20
21/// One labelled block of the brief.
22///
23/// Items rather than a string so the row draws the same facts the new
24/// session was handed, instead of parsing prose back apart.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct HandoffSection {
27    pub label: String,
28    pub lines: Vec<String>,
29}
30
31/// The two halves a successor is given: forge's own facts, then the
32/// handing-over session's judgment.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub struct HandoffBrief {
35    pub ground_truth: Vec<HandoffSection>,
36    pub judgment: Vec<HandoffSection>,
37}
38
39/// The cycle a handoff ended, read from the handover's own transcript.
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct HandoffCycle {
42    pub requests: u32,
43    pub turns: u32,
44    pub prompt_tokens: u64,
45}
46
47/// How the successor's first ten turns went, which is what says whether the
48/// handoff fired too early (a successor re-reading everything) or too late
49/// (a compaction firing first).
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct HandoffSuccessor {
52    /// Turns counted before the measurement settled.
53    pub turns: u32,
54    /// Files the successor read that its predecessor had already read.
55    pub reread_files: u32,
56    /// Questions the successor asked.
57    pub questions: u32,
58    /// User messages that corrected or repeated an instruction.
59    pub corrections: u32,
60}
61
62/// The working tree as the brief reads it, from three cheap git probes.
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct HandoffGitState {
65    /// The current branch, `None` outside a repository.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub branch: Option<String>,
68    /// `HEAD` in short form.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub head: Option<String>,
71    /// `git status --porcelain` lines, capped.
72    #[serde(default)]
73    pub dirty: Vec<String>,
74    /// How many dirty paths there were before the cap, so the brief can say
75    /// what it is not showing.
76    #[serde(default)]
77    pub dirty_total: u32,
78    /// `git diff --stat` lines.
79    #[serde(default)]
80    pub diff: Vec<String>,
81}
82
83/// One handoff, as the swap signal carries it and as the store keeps it for
84/// tuning.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct HandoffRecord {
87    pub slot: SessionSlot,
88    /// The session that handed over.
89    pub from: String,
90    /// The session that continues.
91    pub to: String,
92    pub reason: HandoffReason,
93    /// The fill reading the trigger read, when it had one.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub fill_percent: Option<u8>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub total_tokens: Option<u64>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub max_tokens: Option<u64>,
100    /// The configured share of the window when this fired, so a later config
101    /// change does not rewrite what the record means.
102    pub at_percent: u8,
103    pub brief: HandoffBrief,
104    /// The handover's transcript, which holds everything the brief does not.
105    pub transcript_path: String,
106    /// RFC 3339.
107    pub at: String,
108    #[serde(default)]
109    pub cycle: HandoffCycle,
110    /// Absent until the successor has run its first ten turns.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub successor: Option<HandoffSuccessor>,
113}
114
115impl HandoffRecord {
116    /// The row's one-line address for a view: the pair it swapped.
117    pub fn pair(&self) -> String {
118        format!("{} -> {}", short_id(&self.from), short_id(&self.to))
119    }
120}
121
122/// The short form a row names a session by - the first eight characters,
123/// which is how transcripts are told apart in this project already.
124fn short_id(id: &str) -> &str {
125    id.get(..8).unwrap_or(id)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn record() -> HandoffRecord {
133        HandoffRecord {
134            slot: SessionSlot::lead("Busytools", "forge"),
135            from: "9cfe315c-1111-2222-3333-444444444444".to_owned(),
136            to: "4b21e0a7-5555-6666-7777-888888888888".to_owned(),
137            reason: HandoffReason::Fill,
138            fill_percent: Some(85),
139            total_tokens: Some(867_000),
140            max_tokens: Some(1_020_000),
141            at_percent: 85,
142            brief: HandoffBrief::default(),
143            transcript_path: "/tmp/9cfe315c.jsonl".to_owned(),
144            at: "2026-10-24T08:00:00Z".to_owned(),
145            cycle: HandoffCycle { requests: 514, turns: 96, prompt_tokens: 4_970_000_000 },
146            successor: None,
147        }
148    }
149
150    /// The row is addressed by the pair, and by the short form a reader
151    /// recognises from a transcript name.
152    #[test]
153    fn the_pair_names_both_sessions_short() {
154        assert_eq!(record().pair(), "9cfe315c -> 4b21e0a7");
155    }
156
157    /// The record crosses the wire and the store with its numbers intact, and
158    /// an unmeasured successor stays absent rather than reading as zeros.
159    #[test]
160    fn the_record_round_trips_with_an_unmeasured_successor() {
161        let json = serde_json::to_value(record()).expect("the record serializes");
162        let successor = json.get("successor");
163        assert!(successor.is_none(), "an unmeasured successor is not written");
164        let back: HandoffRecord = serde_json::from_value(json).expect("and reads back");
165        assert_eq!(back, record(), "the record survives the trip unchanged");
166    }
167
168    /// A record written before a field existed still reads, which is what
169    /// keeps an old store row drawable.
170    #[test]
171    fn a_record_without_the_optional_numbers_reads() {
172        let json = serde_json::json!({
173            "slot": {"org": "Busytools", "project": "forge", "label": "lead"},
174            "from": "a", "to": "b", "reason": "manual",
175            "at_percent": 85,
176            "brief": {"ground_truth": [], "judgment": []},
177            "transcript_path": "/tmp/a.jsonl", "at": "now"
178        });
179        let back: HandoffRecord = serde_json::from_value(json).expect("the record reads");
180        assert_eq!(back.reason, HandoffReason::Manual);
181        assert_eq!(back.fill_percent, None, "no reading is not a reading of zero");
182        assert_eq!(back.cycle.requests, 0, "and an absent cycle reads empty");
183    }
184}