fix(snapshot): harden side-git restore wiring
This commit is contained in:
@@ -28,6 +28,7 @@ DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives Dee
|
||||
- **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved). Decomposition-first system prompts teach the model to `todo_write`, `update_plan`, and spawn sub-agents before acting
|
||||
- **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab
|
||||
- **Session save/resume** — checkpoint and resume long sessions
|
||||
- **Workspace rollback** — side-git pre/post-turn snapshots with `/restore` and `revert_turn`, without touching your repo's `.git`
|
||||
- **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows
|
||||
- **MCP protocol** — connect to Model Context Protocol servers for extended tooling
|
||||
- **Live cost tracking** — per-turn and session-level token usage and cost estimates
|
||||
|
||||
@@ -128,10 +128,39 @@ pub struct ConfigToml {
|
||||
/// to a permissive default that mirrors pre-v0.7.0 behavior.
|
||||
#[serde(default)]
|
||||
pub network: Option<NetworkPolicyToml>,
|
||||
/// Workspace side-git snapshots (#137). The live TUI defaults this to
|
||||
/// enabled with 7-day retention when absent.
|
||||
#[serde(default)]
|
||||
pub snapshots: Option<SnapshotsToml>,
|
||||
#[serde(flatten)]
|
||||
pub extras: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SnapshotsToml {
|
||||
#[serde(default = "default_snapshots_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_snapshot_max_age_days")]
|
||||
pub max_age_days: u64,
|
||||
}
|
||||
|
||||
fn default_snapshots_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_snapshot_max_age_days() -> u64 {
|
||||
7
|
||||
}
|
||||
|
||||
impl Default for SnapshotsToml {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_snapshots_enabled(),
|
||||
max_age_days: default_snapshot_max_age_days(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
|
||||
/// for documentation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -192,6 +192,41 @@ pub struct NotificationsConfig {
|
||||
pub include_summary: bool,
|
||||
}
|
||||
|
||||
fn default_snapshots_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_snapshot_max_age_days() -> u64 {
|
||||
crate::snapshot::DEFAULT_MAX_AGE.as_secs() / (24 * 60 * 60)
|
||||
}
|
||||
|
||||
/// Workspace side-git snapshot configuration (#137).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SnapshotsConfig {
|
||||
/// Snapshot the workspace before and after each interactive agent turn.
|
||||
#[serde(default = "default_snapshots_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Prune side-git snapshots older than this many days at session boot.
|
||||
#[serde(default = "default_snapshot_max_age_days")]
|
||||
pub max_age_days: u64,
|
||||
}
|
||||
|
||||
impl Default for SnapshotsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_snapshots_enabled(),
|
||||
max_age_days: default_snapshot_max_age_days(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotsConfig {
|
||||
#[must_use]
|
||||
pub fn max_age(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(self.max_age_days.saturating_mul(24 * 60 * 60))
|
||||
}
|
||||
}
|
||||
|
||||
/// One configurable footer item.
|
||||
///
|
||||
/// Order in the user's `Vec<StatusItem>` is preserved: items in the left
|
||||
@@ -429,6 +464,11 @@ pub struct Config {
|
||||
/// to a permissive default that mirrors pre-v0.7.0 behavior.
|
||||
#[serde(default)]
|
||||
pub network: Option<NetworkPolicyToml>,
|
||||
|
||||
/// Workspace side-git snapshots (#137). Defaults to enabled with 7-day
|
||||
/// retention when the table is absent.
|
||||
#[serde(default)]
|
||||
pub snapshots: Option<SnapshotsConfig>,
|
||||
}
|
||||
|
||||
/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live
|
||||
@@ -875,6 +915,12 @@ impl Config {
|
||||
self.notifications.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve workspace side-git snapshot settings with defaults applied.
|
||||
#[must_use]
|
||||
pub fn snapshots_config(&self) -> SnapshotsConfig {
|
||||
self.snapshots.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve enabled features from defaults and config entries.
|
||||
#[must_use]
|
||||
pub fn features(&self) -> Features {
|
||||
@@ -1384,6 +1430,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
features: merge_features(base.features, override_cfg.features),
|
||||
notifications: override_cfg.notifications.or(base.notifications),
|
||||
network: override_cfg.network.or(base.network),
|
||||
snapshots: override_cfg.snapshots.or(base.snapshots),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ pub struct EngineConfig {
|
||||
/// session-scoped approvals (`/network allow <host>`) persist for the
|
||||
/// remainder of the run.
|
||||
pub network_policy: Option<crate::network_policy::NetworkPolicyDecider>,
|
||||
/// Whether to take side-git workspace snapshots before/after each turn.
|
||||
pub snapshots_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for EngineConfig {
|
||||
@@ -131,6 +133,7 @@ impl Default for EngineConfig {
|
||||
plan_state: new_shared_plan_state(),
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
network_policy: None,
|
||||
snapshots_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1436,14 +1439,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);
|
||||
});
|
||||
// Snapshot the workspace BEFORE we touch a single tool. Run the git
|
||||
// work on the blocking pool so the async runtime stays responsive;
|
||||
// failure is non-fatal (the helper logs at WARN).
|
||||
if self.config.snapshots_enabled {
|
||||
let pre_workspace = self.session.workspace.clone();
|
||||
let pre_seq = self.turn_counter;
|
||||
let _ = tokio::task::spawn_blocking(move || pre_turn_snapshot(&pre_workspace, pre_seq))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Emit turn started event
|
||||
let _ = self
|
||||
@@ -1667,11 +1671,13 @@ impl Engine {
|
||||
|
||||
// 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);
|
||||
});
|
||||
if self.config.snapshots_enabled {
|
||||
let post_workspace = self.session.workspace.clone();
|
||||
let post_seq = self.turn_counter;
|
||||
let _ =
|
||||
tokio::task::spawn_blocking(move || post_turn_snapshot(&post_workspace, post_seq))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Checkpoint-restart cycle boundary (issue #124). The turn just
|
||||
// settled cleanly — no in-flight tools, no streaming, no pending
|
||||
|
||||
@@ -2791,7 +2791,10 @@ async fn run_interactive(
|
||||
// Prune stale workspace snapshots from prior sessions (7-day default).
|
||||
// Non-fatal: a flaky disk, missing `git`, or read-only home should
|
||||
// never block the TUI from starting.
|
||||
session_manager::prune_workspace_snapshots_at_boot(&workspace);
|
||||
let snapshots = config.snapshots_config();
|
||||
if snapshots.enabled {
|
||||
session_manager::prune_workspace_snapshots(&workspace, snapshots.max_age());
|
||||
}
|
||||
|
||||
tui::run_tui(
|
||||
config,
|
||||
@@ -2956,6 +2959,7 @@ async fn run_exec_agent(
|
||||
plan_state: new_shared_plan_state(),
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
network_policy,
|
||||
snapshots_enabled: config.snapshots_config().enabled,
|
||||
};
|
||||
|
||||
let engine_handle = spawn_engine(engine_config, config);
|
||||
|
||||
@@ -1390,6 +1390,7 @@ impl RuntimeThreadManager {
|
||||
plan_state: new_shared_plan_state(),
|
||||
max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH,
|
||||
network_policy,
|
||||
snapshots_enabled: self.config.snapshots_config().enabled,
|
||||
};
|
||||
|
||||
let engine = spawn_engine(engine_cfg, &self.config);
|
||||
|
||||
@@ -370,16 +370,6 @@ pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
|
||||
Ok(home.join(".deepseek").join("sessions"))
|
||||
}
|
||||
|
||||
/// Boot-time hook: prune workspace snapshots older than the configured
|
||||
/// retention window for `workspace`. Failure is logged but never fatal —
|
||||
/// the session boot must not block on a flaky disk or missing `git`.
|
||||
///
|
||||
/// Called once per `run_interactive` invocation. The default retention
|
||||
/// (7 days) is governed by `crate::snapshot::DEFAULT_MAX_AGE`.
|
||||
pub fn prune_workspace_snapshots_at_boot(workspace: &Path) {
|
||||
prune_workspace_snapshots(workspace, crate::snapshot::DEFAULT_MAX_AGE);
|
||||
}
|
||||
|
||||
/// Prune snapshots older than `max_age` for `workspace`.
|
||||
///
|
||||
/// Always non-fatal. Returns silently — callers don't need the count
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
//! project independently — `git worktree list` users won't get cross-talk
|
||||
//! between feature branches.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -72,13 +70,15 @@ fn strip_worktree_suffix(path: &Path) -> PathBuf {
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
/// Hex-encoded `DefaultHasher` digest. Sufficient for directory naming
|
||||
/// (collision risk is negligible for the small set of paths we care
|
||||
/// about, and we'd rather not pull in `sha2` for a 16-byte tag).
|
||||
/// Hex-encoded deterministic FNV-1a digest. This is only a directory tag, not
|
||||
/// a security boundary, but it must remain stable across process launches.
|
||||
fn stable_hex(path: &Path) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
path.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for byte in path.to_string_lossy().as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{hash:016x}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+195
-23
@@ -12,8 +12,9 @@
|
||||
//! repo, the command fails fast instead of falling back to "current
|
||||
//! directory".
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -110,10 +111,10 @@ impl SnapshotRepo {
|
||||
|
||||
/// Take a snapshot of the current working tree.
|
||||
///
|
||||
/// Internally: `git add -A` then `git commit --allow-empty -m <label>`.
|
||||
/// `git add -A` honours the user's workspace `.gitignore` because we
|
||||
/// keep the side repo's `core.excludesFile` empty and let git read
|
||||
/// the workspace's own `.gitignore` files when staging.
|
||||
/// Internally: `git add -A`, `git write-tree`, `git commit-tree`, then
|
||||
/// `git update-ref HEAD <commit>`.
|
||||
/// `git add -A` honours the user's workspace ignore rules while staging
|
||||
/// into the side repo's index.
|
||||
///
|
||||
/// Returns the snapshot's commit SHA.
|
||||
pub fn snapshot(&self, label: &str) -> io::Result<SnapshotId> {
|
||||
@@ -128,36 +129,58 @@ impl SnapshotRepo {
|
||||
)));
|
||||
}
|
||||
|
||||
// `--allow-empty` so back-to-back snapshots with no changes
|
||||
// still produce a marker commit (otherwise `/restore N` indices
|
||||
// would skip turns where nothing changed).
|
||||
let commit = run_git(
|
||||
let tree = run_git(&self.git_dir, &self.work_tree, &["write-tree"])?;
|
||||
if !tree.status.success() {
|
||||
return Err(io_other(format!(
|
||||
"git write-tree failed: {}",
|
||||
String::from_utf8_lossy(&tree.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let tree = String::from_utf8_lossy(&tree.stdout).trim().to_string();
|
||||
|
||||
let parent = run_git(
|
||||
&self.git_dir,
|
||||
&self.work_tree,
|
||||
&[
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"--no-verify",
|
||||
"--no-gpg-sign",
|
||||
"-m",
|
||||
label,
|
||||
],
|
||||
&["rev-parse", "--verify", "HEAD"],
|
||||
)?;
|
||||
let parent = parent
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&parent.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let mut args = vec!["commit-tree".to_string(), tree];
|
||||
if let Some(parent) = parent {
|
||||
args.push("-p".to_string());
|
||||
args.push(parent);
|
||||
}
|
||||
args.push("-m".to_string());
|
||||
args.push(label.to_string());
|
||||
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
|
||||
// `commit-tree` creates marker commits even when the tree matches its
|
||||
// parent, and it does not run user/global commit hooks.
|
||||
let commit = run_git(&self.git_dir, &self.work_tree, &arg_refs)?;
|
||||
if !commit.status.success() {
|
||||
return Err(io_other(format!(
|
||||
"git commit failed: {}",
|
||||
"git commit-tree failed: {}",
|
||||
String::from_utf8_lossy(&commit.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
|
||||
|
||||
let head = run_git(&self.git_dir, &self.work_tree, &["rev-parse", "HEAD"])?;
|
||||
if !head.status.success() {
|
||||
let update = run_git(
|
||||
&self.git_dir,
|
||||
&self.work_tree,
|
||||
&["update-ref", "HEAD", &sha],
|
||||
)?;
|
||||
if !update.status.success() {
|
||||
return Err(io_other(format!(
|
||||
"git rev-parse HEAD failed: {}",
|
||||
String::from_utf8_lossy(&head.stderr).trim()
|
||||
"git update-ref HEAD failed: {}",
|
||||
String::from_utf8_lossy(&update.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||||
|
||||
Ok(SnapshotId(sha))
|
||||
}
|
||||
|
||||
@@ -167,6 +190,8 @@ impl SnapshotRepo {
|
||||
/// snapshot tree relative to the workspace root. We do NOT touch the
|
||||
/// user's own `.git` — snapshots only contain working-tree files.
|
||||
pub fn restore(&self, id: &SnapshotId) -> io::Result<()> {
|
||||
let current_paths = self.tree_paths("HEAD")?;
|
||||
let target_paths = self.tree_paths(id.as_str())?;
|
||||
let checkout = run_git(
|
||||
&self.git_dir,
|
||||
&self.work_tree,
|
||||
@@ -178,9 +203,60 @@ impl SnapshotRepo {
|
||||
String::from_utf8_lossy(&checkout.stderr).trim()
|
||||
)));
|
||||
}
|
||||
self.remove_paths_missing_from_target(¤t_paths, &target_paths)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn tree_paths(&self, treeish: &str) -> io::Result<HashSet<PathBuf>> {
|
||||
let ls = run_git(
|
||||
&self.git_dir,
|
||||
&self.work_tree,
|
||||
&["ls-tree", "-r", "-z", "--name-only", treeish],
|
||||
)?;
|
||||
if !ls.status.success() {
|
||||
return Err(io_other(format!(
|
||||
"git ls-tree failed: {}",
|
||||
String::from_utf8_lossy(&ls.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(parse_nul_paths(&ls.stdout))
|
||||
}
|
||||
|
||||
fn remove_paths_missing_from_target(
|
||||
&self,
|
||||
current_paths: &HashSet<PathBuf>,
|
||||
target_paths: &HashSet<PathBuf>,
|
||||
) -> io::Result<()> {
|
||||
for rel in current_paths.difference(target_paths) {
|
||||
if !is_safe_relative_path(rel) {
|
||||
continue;
|
||||
}
|
||||
let path = self.work_tree.join(rel);
|
||||
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if metadata.file_type().is_dir() {
|
||||
let _ = std::fs::remove_dir(&path);
|
||||
} else {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
self.prune_empty_parent_dirs(path.parent());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prune_empty_parent_dirs(&self, mut dir: Option<&Path>) {
|
||||
while let Some(path) = dir {
|
||||
if path == self.work_tree {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_dir(path).is_err() {
|
||||
break;
|
||||
}
|
||||
dir = path.parent();
|
||||
}
|
||||
}
|
||||
|
||||
/// List up to `limit` most-recent snapshots, newest first.
|
||||
pub fn list(&self, limit: usize) -> io::Result<Vec<Snapshot>> {
|
||||
// `git log -<n>` is the short form of `--max-count=<n>`; if `limit`
|
||||
@@ -331,6 +407,21 @@ fn io_other(msg: impl Into<String>) -> io::Error {
|
||||
io::Error::other(msg.into())
|
||||
}
|
||||
|
||||
fn parse_nul_paths(bytes: &[u8]) -> HashSet<PathBuf> {
|
||||
bytes
|
||||
.split(|b| *b == 0)
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.map(|chunk| PathBuf::from(String::from_utf8_lossy(chunk).into_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_safe_relative_path(path: &Path) -> bool {
|
||||
!path.as_os_str().is_empty()
|
||||
&& path
|
||||
.components()
|
||||
.all(|component| matches!(component, Component::Normal(_)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -416,6 +507,87 @@ mod tests {
|
||||
assert_eq!(after, "original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_removes_files_added_after_target_snapshot() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let (repo, _home) = make_repo(tmp.path());
|
||||
let original = repo.work_tree().join("original.txt");
|
||||
let added = repo.work_tree().join("added.txt");
|
||||
|
||||
std::fs::write(&original, b"original").unwrap();
|
||||
let id = repo.snapshot("pre-turn:1").expect("snapshot");
|
||||
|
||||
std::fs::write(&added, b"new file").unwrap();
|
||||
repo.snapshot("post-turn:1").expect("snapshot 2");
|
||||
|
||||
repo.restore(&id).expect("restore");
|
||||
assert!(original.exists());
|
||||
assert!(!added.exists(), "restore must remove tracked added files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_and_restore_do_not_move_user_git_head() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&workspace)
|
||||
.arg("init")
|
||||
.arg("--quiet")
|
||||
.status()
|
||||
.unwrap();
|
||||
std::fs::write(workspace.join("tracked.txt"), b"committed").unwrap();
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&workspace)
|
||||
.arg("add")
|
||||
.arg("tracked.txt")
|
||||
.status()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&workspace)
|
||||
.arg("-c")
|
||||
.arg("user.name=user")
|
||||
.arg("-c")
|
||||
.arg("user.email=user@example.test")
|
||||
.arg("commit")
|
||||
.arg("--quiet")
|
||||
.arg("-m")
|
||||
.arg("init")
|
||||
.status()
|
||||
.unwrap();
|
||||
let user_head_before = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&workspace)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout;
|
||||
|
||||
let _home = scoped_home(tmp.path());
|
||||
let repo = SnapshotRepo::open_or_init(&workspace).unwrap();
|
||||
std::fs::write(workspace.join("tracked.txt"), b"dirty-before").unwrap();
|
||||
let id = repo.snapshot("pre-turn:1").unwrap();
|
||||
std::fs::write(workspace.join("tracked.txt"), b"dirty-after").unwrap();
|
||||
repo.snapshot("post-turn:1").unwrap();
|
||||
repo.restore(&id).unwrap();
|
||||
|
||||
let user_head_after = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&workspace)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout;
|
||||
assert_eq!(user_head_after, user_head_before);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(workspace.join("tracked.txt")).unwrap(),
|
||||
"dirty-before"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_respects_limit() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -468,7 +468,8 @@ impl ToolRegistryBuilder {
|
||||
.with_diagnostics_tool()
|
||||
.with_project_tools()
|
||||
.with_test_runner_tool()
|
||||
.with_validation_tools();
|
||||
.with_validation_tools()
|
||||
.with_revert_turn_tool();
|
||||
|
||||
if allow_shell {
|
||||
builder.with_shell_tools()
|
||||
@@ -505,7 +506,6 @@ impl ToolRegistryBuilder {
|
||||
.with_review_tool(client.clone(), model.clone())
|
||||
.with_rlm_tool(client, model)
|
||||
.with_recall_archive_tool()
|
||||
.with_revert_turn_tool()
|
||||
.with_subagent_tools(manager, runtime)
|
||||
}
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
network_policy: config.network.clone().map(|toml_cfg| {
|
||||
crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
|
||||
}),
|
||||
snapshots_enabled: config.snapshots_config().enabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ drives turns through Chat Completions.
|
||||
3. While degraded/offline, new prompts are queued in-memory and mirrored to `~/.deepseek/sessions/checkpoints/offline_queue.json`
|
||||
4. Queue edits (`/queue ...`) are persisted continuously so drafts and queued prompts survive restarts
|
||||
5. Successful turn completion clears the active checkpoint and writes a durable session snapshot
|
||||
6. Agent/Yolo turns also take pre/post-turn side-git workspace snapshots under `~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git`; `/restore N` and `revert_turn` restore file state without changing conversation history or the user's `.git`
|
||||
|
||||
### Tool Execution
|
||||
|
||||
@@ -249,5 +250,6 @@ command = "echo 'Running tool: $TOOL_NAME'"
|
||||
- `~/.deepseek/skills/` - User skills directory
|
||||
- `~/.deepseek/sessions/` - Session history
|
||||
- `~/.deepseek/sessions/checkpoints/` - Crash checkpoint + offline queue persistence
|
||||
- `~/.deepseek/snapshots/` - Side-git pre/post-turn workspace snapshots for `/restore` and `revert_turn`
|
||||
- `~/.deepseek/tasks/` - Background task records, queue, timelines, artifacts
|
||||
- `~/.deepseek/audit.log` - Append-only audit events for credential + approval/elevation actions
|
||||
|
||||
@@ -170,6 +170,10 @@ If you are upgrading from older releases:
|
||||
- `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`.
|
||||
- `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the `note` tool.
|
||||
- `memory_path` (string, optional): defaults to `~/.deepseek/memory.md`.
|
||||
- `snapshots.*` (optional): side-git workspace snapshots for file rollback:
|
||||
- `[snapshots].enabled` (bool, default `true`)
|
||||
- `[snapshots].max_age_days` (int, default `7`)
|
||||
- snapshots live under `~/.deepseek/snapshots/<project_hash>/<worktree_hash>/.git` and never use the workspace's own `.git` directory
|
||||
- `retry.*` (optional): retry/backoff settings for API requests:
|
||||
- `[retry].enabled` (bool, default `true`)
|
||||
- `[retry].max_retries` (int, default `3`)
|
||||
|
||||
Reference in New Issue
Block a user