feat(core): #137 add pre/post-turn snapshot hooks

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) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-28 00:31:31 -05:00
parent 3dc116b9fc
commit 8ff4f66b95
2 changed files with 60 additions and 1 deletions
+18 -1
View File
@@ -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
+42
View File
@@ -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<u32>, delta: Option<u32>) -> Option<u32> {
}
}
/// Take a `pre-turn:<seq>` 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<String> {
snapshot_with_label(workspace, &format!("pre-turn:{turn_seq}"))
}
/// Take a `post-turn:<seq>` workspace snapshot. Same failure model as
/// [`pre_turn_snapshot`].
pub fn post_turn_snapshot(workspace: &Path, turn_seq: u64) -> Option<String> {
snapshot_with_label(workspace, &format!("post-turn:{turn_seq}"))
}
fn snapshot_with_label(workspace: &Path, label: &str) -> Option<String> {
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 {