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 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 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 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}