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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user