From 8ff4f66b951bcd00d3f70b95f43e49f749a7ff01 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 00:31:31 -0500 Subject: [PATCH] feat(core): #137 add pre/post-turn snapshot hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `pre_turn_snapshot` and `post_turn_snapshot` helpers into `core::turn`, then call them from `Engine::handle_send_message` — pre-turn fires right after `turn_counter` is incremented, post-turn fires right after `Event::TurnComplete` is emitted. Both hooks are dispatched via `tokio::task::spawn_blocking` so the agent loop never waits on the side-git commit, and helper failures are swallowed at WARN log level so a busted disk or missing `git` binary can never derail a turn (per the snapshot module's documented non-fatal contract). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/core/engine.rs | 19 +++++++++++++++- crates/tui/src/core/turn.rs | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6175a467..02bcf7e7 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -61,7 +61,7 @@ use super::events::{Event, TurnOutcomeStatus}; use super::ops::Op; use super::session::Session; use super::tool_parser; -use super::turn::{TurnContext, TurnToolCall}; +use super::turn::{TurnContext, TurnToolCall, post_turn_snapshot, pre_turn_snapshot}; // === Types === @@ -1436,6 +1436,15 @@ impl Engine { self.turn_counter = self.turn_counter.saturating_add(1); self.capacity_controller.mark_turn_start(self.turn_counter); + // Snapshot the workspace BEFORE we touch a single tool. Spawn on + // the blocking pool so the agent loop never waits on the side-git + // commit; failure is non-fatal (the helper logs at WARN). + let pre_workspace = self.session.workspace.clone(); + let pre_seq = self.turn_counter; + tokio::task::spawn_blocking(move || { + let _ = pre_turn_snapshot(&pre_workspace, pre_seq); + }); + // Emit turn started event let _ = self .tx_event @@ -1656,6 +1665,14 @@ impl Engine { }) .await; + // Post-turn snapshot. Same non-blocking, non-fatal contract as + // the pre-turn hook above. + let post_workspace = self.session.workspace.clone(); + let post_seq = self.turn_counter; + tokio::task::spawn_blocking(move || { + let _ = post_turn_snapshot(&post_workspace, post_seq); + }); + // Checkpoint-restart cycle boundary (issue #124). The turn just // settled cleanly — no in-flight tools, no streaming, no pending // approval — so this is the safe phase to swap the context if we've diff --git a/crates/tui/src/core/turn.rs b/crates/tui/src/core/turn.rs index ea187710..bc0706e2 100644 --- a/crates/tui/src/core/turn.rs +++ b/crates/tui/src/core/turn.rs @@ -2,8 +2,20 @@ //! //! A "turn" is one user message and the resulting AI response, //! including any tool calls that occur. +//! +//! ## Snapshot lifecycle hooks +//! +//! [`pre_turn_snapshot`] and [`post_turn_snapshot`] book-end a turn by +//! taking a workspace-level snapshot into a side git repo (see +//! `crate::snapshot`). They are intentionally non-blocking and +//! non-fatal: any IO error is logged at WARN and swallowed so a busted +//! filesystem or missing `git` binary never derails the agent loop. +//! `/restore N` and the `revert_turn` tool both consume these +//! snapshots. use crate::models::Usage; +use crate::snapshot::SnapshotRepo; +use std::path::Path; use std::time::{Duration, Instant}; /// Context for a single turn (user message + AI response). @@ -116,6 +128,36 @@ fn add_optional_usage(total: Option, delta: Option) -> Option { } } +/// Take a `pre-turn:` workspace snapshot. +/// +/// Returns the snapshot SHA on success, `None` on any error. Errors are +/// logged at WARN; the turn loop must not block on this. +pub fn pre_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option { + snapshot_with_label(workspace, &format!("pre-turn:{turn_seq}")) +} + +/// Take a `post-turn:` workspace snapshot. Same failure model as +/// [`pre_turn_snapshot`]. +pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option { + snapshot_with_label(workspace, &format!("post-turn:{turn_seq}")) +} + +fn snapshot_with_label(workspace: &Path, label: &str) -> Option { + match SnapshotRepo::open_or_init(workspace) { + Ok(repo) => match repo.snapshot(label) { + Ok(id) => Some(id.0), + Err(e) => { + tracing::warn!(target: "snapshot", "snapshot '{label}' failed: {e}"); + None + } + }, + Err(e) => { + tracing::warn!(target: "snapshot", "snapshot repo init failed: {e}"); + None + } + } +} + impl TurnToolCall { /// Create a new tool call record pub fn new(id: String, name: String, input: serde_json::Value) -> Self {