Skip to main content

falsegreen_agent/
session.rs

1use serde::{Deserialize, Serialize};
2use serde_json::json;
3use thiserror::Error;
4use uuid::Uuid;
5
6use crate::event::{
7    Event, EventError, EventKind, EventStore, SessionReplacementRecord, history_sha256,
8};
9use crate::workspace::{Workspace, WorkspaceError, WorkspaceState};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum SessionState {
14    Initializing,
15    Working,
16    WaitingForTool,
17    CandidateReady,
18    Verifying,
19    Repairing,
20    Paused,
21    AcceptedAwaitingAuthority,
22    Completed,
23    Failed,
24    BudgetExhausted,
25}
26
27impl SessionState {
28    #[must_use]
29    pub const fn is_terminal(self) -> bool {
30        matches!(
31            self,
32            Self::AcceptedAwaitingAuthority
33                | Self::Completed
34                | Self::Failed
35                | Self::BudgetExhausted
36        )
37    }
38}
39
40#[derive(Debug, Error)]
41pub enum SessionError {
42    #[error(transparent)]
43    Event(#[from] EventError),
44    #[error("session {0} does not exist")]
45    NotFound(String),
46    #[error("session history is missing its user goal")]
47    MissingGoal,
48    #[error("invalid persisted session state: {0}")]
49    InvalidState(String),
50    #[error("session history contains events after terminal state")]
51    EventsAfterTerminal,
52    #[error("only infrastructure-failed sessions may be replaced")]
53    IneligiblePredecessor,
54    #[error("failed predecessor has no eligible candidate provenance")]
55    MissingCandidateProvenance,
56    #[error("candidate SHA-256 has an invalid format")]
57    InvalidCandidateIdentity,
58    #[error("candidate identity mismatch (expected {expected}, found {actual})")]
59    CandidateMismatch { expected: String, actual: String },
60    #[error("candidate workspace provenance is malformed: {0}")]
61    InvalidCandidateProvenance(String),
62    #[error("replacement session chains are not supported")]
63    ReplacementChainUnsupported,
64    #[error("replacement session is not in its verification-only candidate state")]
65    InvalidReplacementExecutionState,
66    #[error("replacement workspace identity is missing or malformed")]
67    InvalidReplacementWorkspaceProvenance,
68    #[error("replacement workspace identity differs from its persisted candidate source")]
69    ReplacementWorkspaceMismatch,
70    #[error(transparent)]
71    Workspace(#[from] WorkspaceError),
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Session {
76    pub id: String,
77    pub goal: String,
78    pub state: SessionState,
79    pub model_turns: u32,
80    pub tool_calls: u32,
81    pub repair_cycles: u32,
82    pub started_at_ms: u64,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct CreatedReplacementSession {
87    pub session: Session,
88    pub replacement: SessionReplacementRecord,
89}
90
91impl Session {
92    pub fn create(store: &mut EventStore, goal: impl Into<String>) -> Result<Self, SessionError> {
93        let goal = goal.into();
94        let id = format!("session_{}", Uuid::new_v4().simple());
95        let created = store.append(
96            &id,
97            EventKind::SessionCreated,
98            &json!({
99                "state": SessionState::Initializing,
100                "harness_name": env!("CARGO_PKG_NAME"),
101                "harness_version": env!("CARGO_PKG_VERSION")
102            }),
103        )?;
104        store.append(&id, EventKind::UserGoal, &json!({"goal": goal}))?;
105        let mut session = Self {
106            id,
107            goal,
108            state: SessionState::Initializing,
109            model_turns: 0,
110            tool_calls: 0,
111            repair_cycles: 0,
112            started_at_ms: created.created_at_ms,
113        };
114        session.transition(store, SessionState::Working, "initialized")?;
115        Ok(session)
116    }
117
118    pub fn reconstruct(store: &EventStore, id: &str) -> Result<Self, SessionError> {
119        let events = store.events(id)?;
120        if events.is_empty() {
121            return Err(SessionError::NotFound(id.to_owned()));
122        }
123        let goal = events
124            .iter()
125            .find(|event| event.kind == EventKind::UserGoal)
126            .and_then(|event| event.payload.get("goal"))
127            .and_then(|value| value.as_str())
128            .ok_or(SessionError::MissingGoal)?
129            .to_owned();
130        let started_at_ms = events[0].created_at_ms;
131        let mut session = Self {
132            id: id.to_owned(),
133            goal,
134            state: SessionState::Initializing,
135            model_turns: 0,
136            tool_calls: 0,
137            repair_cycles: 0,
138            started_at_ms,
139        };
140        let mut terminal_seen = false;
141        for event in &events {
142            if event.kind.is_composition() {
143                // Composition audit metadata is deliberately not part of
144                // Agent lifecycle state and can never downgrade or advance a
145                // terminal session, even when emitted after TerminalState.
146                continue;
147            }
148            if terminal_seen {
149                return Err(SessionError::EventsAfterTerminal);
150            }
151            match event.kind {
152                EventKind::ModelRequest => session.model_turns += 1,
153                EventKind::ToolRequest => session.tool_calls += 1,
154                EventKind::RepairStarted => session.repair_cycles += 1,
155                EventKind::TerminalState => {
156                    session.state = state_from_event(event)?;
157                    terminal_seen = true;
158                }
159                _ => {
160                    if let Some(state) = event.payload.get("state") {
161                        session.state = serde_json::from_value(state.clone())
162                            .map_err(|_| SessionError::InvalidState(state.to_string()))?;
163                    }
164                }
165            }
166        }
167        let replacement = store.replacement_for_session(id)?;
168        if replacement.is_none()
169            && events
170                .iter()
171                .any(|event| event.kind == EventKind::SessionReplaced)
172        {
173            return Err(EventError::MalformedReplacement(
174                "replacement event has no durable relation".to_owned(),
175            )
176            .into());
177        }
178        Ok(session)
179    }
180
181    pub fn create_replacement(
182        store: &mut EventStore,
183        workspace: &Workspace,
184        predecessor_session_id: &str,
185        expected_candidate_sha256: &str,
186    ) -> Result<CreatedReplacementSession, SessionError> {
187        if !valid_sha256(expected_candidate_sha256) {
188            return Err(SessionError::InvalidCandidateIdentity);
189        }
190        if store
191            .replacement_for_session(predecessor_session_id)?
192            .is_some()
193        {
194            return Err(SessionError::ReplacementChainUnsupported);
195        }
196        if let Some(existing) = store.replacement_for_predecessor(predecessor_session_id)? {
197            return Err(EventError::ReplacementAlreadyExists {
198                predecessor: existing.predecessor_session_id,
199                replacement: existing.replacement_session_id,
200            }
201            .into());
202        }
203
204        let predecessor = Self::reconstruct(store, predecessor_session_id)?;
205        let predecessor_events = store.events(predecessor_session_id)?;
206        let core_events: Vec<&Event> = predecessor_events
207            .iter()
208            .filter(|event| !event.kind.is_composition())
209            .collect();
210        let Some(terminal) = core_events.last() else {
211            return Err(SessionError::IneligiblePredecessor);
212        };
213        if predecessor.state != SessionState::Failed
214            || terminal.kind != EventKind::TerminalState
215            || terminal.payload["state"] != json!(SessionState::Failed)
216            || terminal.payload["reason"] != "falsegreen_infrastructure_failure"
217        {
218            return Err(SessionError::IneligiblePredecessor);
219        }
220        let failure = core_events
221            .get(core_events.len().saturating_sub(2))
222            .filter(|event| event.kind == EventKind::FalsegreenResult)
223            .ok_or(SessionError::IneligiblePredecessor)?;
224        if failure.payload["verdict"] != "insufficient_evidence"
225            || failure.payload["error"].as_str().is_none_or(str::is_empty)
226        {
227            return Err(SessionError::IneligiblePredecessor);
228        }
229
230        let (candidate_event, git_state_event, candidate_summary) =
231            eligible_candidate_provenance(&predecessor_events)?;
232        let persisted_candidate = candidate_event.payload["candidate_sha256"]
233            .as_str()
234            .ok_or(SessionError::MissingCandidateProvenance)?;
235        if persisted_candidate != expected_candidate_sha256 {
236            return Err(SessionError::CandidateMismatch {
237                expected: expected_candidate_sha256.to_owned(),
238                actual: persisted_candidate.to_owned(),
239            });
240        }
241        let actual_candidate = workspace.candidate_sha256()?;
242        if actual_candidate != expected_candidate_sha256 {
243            return Err(SessionError::CandidateMismatch {
244                expected: expected_candidate_sha256.to_owned(),
245                actual: actual_candidate,
246            });
247        }
248        let persisted_workspace: WorkspaceState =
249            serde_json::from_value(git_state_event.payload.clone())
250                .map_err(|error| SessionError::InvalidCandidateProvenance(error.to_string()))?;
251        let current_workspace = workspace.state()?;
252        if persisted_workspace != current_workspace {
253            return Err(SessionError::InvalidCandidateProvenance(
254                "current Git/workspace identity differs from the candidate source".to_owned(),
255            ));
256        }
257
258        let authority_events: Vec<&Event> = predecessor_events
259            .iter()
260            .filter(|event| {
261                event.kind == EventKind::Checkpoint
262                    && event.payload["checkpoint_kind"] == "acceptance_authority"
263            })
264            .collect();
265        if authority_events.len() != 1 {
266            return Err(SessionError::InvalidCandidateProvenance(
267                "expected exactly one acceptance-authority binding".to_owned(),
268            ));
269        }
270        let falsegreen_task_id = authority_events[0].payload["falsegreen_task_id"]
271            .as_str()
272            .filter(|value| !value.is_empty())
273            .ok_or_else(|| {
274                SessionError::InvalidCandidateProvenance(
275                    "acceptance-authority task binding is missing".to_owned(),
276                )
277            })?
278            .to_owned();
279        let predecessor_history_sha256 = history_sha256(&predecessor_events)?;
280        let replacement_session_id = format!("session_{}", Uuid::new_v4().simple());
281        let record = SessionReplacementRecord {
282            predecessor_session_id: predecessor_session_id.to_owned(),
283            replacement_session_id: replacement_session_id.clone(),
284            predecessor_state: "failed".to_owned(),
285            predecessor_history_sha256: predecessor_history_sha256.clone(),
286            candidate_sha256: expected_candidate_sha256.to_owned(),
287            candidate_event_sequence: candidate_event.sequence,
288            source_git_state_sequence: git_state_event.sequence,
289            falsegreen_task_id: falsegreen_task_id.clone(),
290            created_at_ms: 0,
291        };
292        let replacement_payload = json!({
293            "predecessor_session_id": predecessor_session_id,
294            "predecessor_state": "failed",
295            "predecessor_history_sha256": predecessor_history_sha256,
296            "candidate_sha256": expected_candidate_sha256,
297            "candidate_event_sequence": candidate_event.sequence,
298            "source_git_state_sequence": git_state_event.sequence,
299            "falsegreen_task_id": falsegreen_task_id,
300            "topology": "single_direct_replacement_no_chains"
301        });
302        let mut authority_payload = authority_events[0].payload.clone();
303        authority_payload["replacement_predecessor_session_id"] = json!(predecessor_session_id);
304        let event_payloads = vec![
305            (
306                EventKind::SessionCreated,
307                json!({
308                    "state": SessionState::Initializing,
309                    "harness_name": env!("CARGO_PKG_NAME"),
310                    "harness_version": env!("CARGO_PKG_VERSION")
311                }),
312            ),
313            (EventKind::UserGoal, json!({"goal": predecessor.goal})),
314            (
315                EventKind::Checkpoint,
316                json!({
317                    "state": SessionState::Working,
318                    "previous_state": SessionState::Initializing,
319                    "reason": "replacement_initialized"
320                }),
321            ),
322            (EventKind::SessionReplaced, replacement_payload),
323            (EventKind::Checkpoint, authority_payload),
324            (
325                EventKind::CandidateReady,
326                json!({
327                    "summary": candidate_summary,
328                    "candidate_sha256": expected_candidate_sha256,
329                    "carried_forward": true,
330                    "predecessor_session_id": predecessor_session_id,
331                    "predecessor_candidate_event_sequence": candidate_event.sequence
332                }),
333            ),
334            (EventKind::GitState, git_state_event.payload.clone()),
335            (
336                EventKind::Checkpoint,
337                json!({
338                    "checkpoint_kind": "replacement_candidate_validation",
339                    "state": SessionState::CandidateReady,
340                    "previous_state": SessionState::Working,
341                    "reason": "authorized_candidate_carried_forward",
342                    "expected_candidate_sha256": expected_candidate_sha256,
343                    "actual_candidate_sha256": expected_candidate_sha256,
344                    "matched": true,
345                    "predecessor_session_id": predecessor_session_id
346                }),
347            ),
348        ];
349        let replacement = store.create_session_replacement(record, &event_payloads)?;
350        let session = Self::reconstruct(store, &replacement_session_id)?;
351        Ok(CreatedReplacementSession {
352            session,
353            replacement,
354        })
355    }
356
357    /// Validate the durable verification-only execution class before runtime or verification.
358    pub fn validate_replacement_resume(
359        &self,
360        store: &EventStore,
361        workspace: &Workspace,
362    ) -> Result<Option<SessionReplacementRecord>, SessionError> {
363        let Some(replacement) = store.replacement_for_session(&self.id)? else {
364            return Ok(None);
365        };
366        if self.state.is_terminal() {
367            return Ok(Some(replacement));
368        }
369        if self.state != SessionState::CandidateReady
370            || self.model_turns != 0
371            || self.tool_calls != 0
372            || self.repair_cycles != 0
373        {
374            return Err(SessionError::InvalidReplacementExecutionState);
375        }
376        let events = store.events(&self.id)?;
377        let candidate_index = events
378            .iter()
379            .rposition(|event| {
380                event.kind == EventKind::CandidateReady
381                    && event.payload["carried_forward"].as_bool() == Some(true)
382                    && event.payload["candidate_sha256"].as_str()
383                        == Some(&replacement.candidate_sha256)
384            })
385            .ok_or(SessionError::InvalidReplacementExecutionState)?;
386        if events[candidate_index + 1..].iter().any(|event| {
387            matches!(
388                event.kind,
389                EventKind::ModelRequest
390                    | EventKind::ModelResponse
391                    | EventKind::ToolRequest
392                    | EventKind::ToolResult
393                    | EventKind::FileMutation
394                    | EventKind::CommandExecution
395                    | EventKind::FalsegreenResult
396                    | EventKind::RepairStarted
397                    | EventKind::GenUiActionPresented
398                    | EventKind::GenUiActionConfirmationRequested
399                    | EventKind::GenUiActionConfirmed
400                    | EventKind::GenUiActionExecutionStarted
401                    | EventKind::GenUiActionExecutionCompleted
402                    | EventKind::GenUiActionExecutionFailed
403                    | EventKind::GenUiActionExecutionUnknown
404                    | EventKind::TerminalState
405            )
406        }) {
407            return Err(SessionError::InvalidReplacementExecutionState);
408        }
409        let git_states: Vec<&Event> = events[candidate_index + 1..]
410            .iter()
411            .filter(|event| event.kind == EventKind::GitState)
412            .collect();
413        if git_states.len() != 1 {
414            return Err(SessionError::InvalidReplacementWorkspaceProvenance);
415        }
416        let persisted_workspace: WorkspaceState =
417            serde_json::from_value(git_states[0].payload.clone())
418                .map_err(|_| SessionError::InvalidReplacementWorkspaceProvenance)?;
419        let actual_candidate = workspace.candidate_sha256()?;
420        if actual_candidate != replacement.candidate_sha256 {
421            return Err(SessionError::CandidateMismatch {
422                expected: replacement.candidate_sha256,
423                actual: actual_candidate,
424            });
425        }
426        // Compare portable candidate content first so a changed byte reports
427        // the precise candidate mismatch. Only an unchanged candidate then
428        // reaches the stricter host/workspace identity comparison.
429        if persisted_workspace != workspace.state()? {
430            return Err(SessionError::ReplacementWorkspaceMismatch);
431        }
432        Ok(Some(replacement))
433    }
434
435    pub fn transition(
436        &mut self,
437        store: &mut EventStore,
438        next: SessionState,
439        reason: &str,
440    ) -> Result<(), SessionError> {
441        let previous = self.state;
442        let kind = if next.is_terminal() {
443            EventKind::TerminalState
444        } else {
445            EventKind::Checkpoint
446        };
447        store.append(
448            &self.id,
449            kind,
450            &json!({"state": next, "previous_state": previous, "reason": reason}),
451        )?;
452        self.state = next;
453        Ok(())
454    }
455}
456
457fn eligible_candidate_provenance(
458    events: &[Event],
459) -> Result<(&Event, &Event, String), SessionError> {
460    let candidate_index = events
461        .iter()
462        .rposition(|event| event.kind == EventKind::CandidateReady)
463        .ok_or(SessionError::MissingCandidateProvenance)?;
464    let candidate = &events[candidate_index];
465    let digest = candidate.payload["candidate_sha256"]
466        .as_str()
467        .filter(|value| valid_sha256(value))
468        .ok_or(SessionError::MissingCandidateProvenance)?;
469    let summary = candidate.payload["summary"]
470        .as_str()
471        .filter(|value| !value.trim().is_empty())
472        .ok_or(SessionError::MissingCandidateProvenance)?
473        .to_owned();
474    let boundary = events[..candidate_index]
475        .iter()
476        .rev()
477        .find(|event| {
478            event.kind == EventKind::Checkpoint
479                && event.payload["checkpoint_kind"] == "turn_boundary"
480        })
481        .filter(|event| event.payload["candidate_sha256"].as_str() == Some(digest))
482        .ok_or(SessionError::MissingCandidateProvenance)?;
483    let git_states: Vec<&Event> = events[candidate_index + 1..]
484        .iter()
485        .filter(|event| event.kind == EventKind::GitState)
486        .collect();
487    let source_activity_after_candidate = events[candidate_index + 1..].iter().any(|event| {
488        matches!(
489            event.kind,
490            EventKind::ModelRequest
491                | EventKind::ModelResponse
492                | EventKind::ToolRequest
493                | EventKind::ToolResult
494                | EventKind::FileMutation
495                | EventKind::CommandExecution
496                | EventKind::CandidateReady
497                | EventKind::GenUiActionPresented
498                | EventKind::GenUiActionConfirmationRequested
499                | EventKind::GenUiActionConfirmed
500                | EventKind::GenUiActionExecutionStarted
501                | EventKind::GenUiActionExecutionCompleted
502                | EventKind::GenUiActionExecutionFailed
503                | EventKind::GenUiActionExecutionUnknown
504        )
505    });
506    if git_states.len() != 1
507        || boundary.sequence >= candidate.sequence
508        || source_activity_after_candidate
509    {
510        return Err(SessionError::MissingCandidateProvenance);
511    }
512    Ok((candidate, git_states[0], summary))
513}
514
515fn valid_sha256(value: &str) -> bool {
516    value.len() == 64
517        && value
518            .bytes()
519            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
520}
521
522fn state_from_event(event: &Event) -> Result<SessionState, SessionError> {
523    let value = event
524        .payload
525        .get("state")
526        .ok_or_else(|| SessionError::InvalidState(event.payload.to_string()))?;
527    serde_json::from_value(value.clone()).map_err(|_| SessionError::InvalidState(value.to_string()))
528}
529
530#[cfg(test)]
531mod tests {
532    use serde_json::json;
533
534    use crate::event::{EventKind, EventStore};
535
536    use super::{Session, SessionState};
537
538    #[test]
539    fn reconstructs_counts_and_terminal_state() {
540        let mut store = EventStore::open_memory().expect("store");
541        let mut session = Session::create(&mut store, "fix it").expect("create");
542        store
543            .append(&session.id, EventKind::ModelRequest, &json!({}))
544            .expect("model request");
545        store
546            .append(&session.id, EventKind::ToolRequest, &json!({}))
547            .expect("tool request");
548        store
549            .append(&session.id, EventKind::ToolResult, &json!({"ok": true}))
550            .expect("tool result");
551        session
552            .transition(&mut store, SessionState::Failed, "test")
553            .expect("transition");
554
555        let loaded = Session::reconstruct(&store, &session.id).expect("reconstruct");
556        assert_eq!(loaded.goal, "fix it");
557        assert_eq!(loaded.model_turns, 1);
558        assert_eq!(loaded.tool_calls, 1);
559        assert_eq!(loaded.state, SessionState::Failed);
560    }
561}