Files
Kigi-CLI/crates/codegen/kigi-workspace/src/session/checkpoint.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

1022 lines
43 KiB
Rust

//! Turn-boundary fan-out for the workspace.
//!
//! [`WorkspaceHandle::on_turn_boundary`] is the single internal entry point for
//! turn/prompt boundaries.
//!
//! A rewind checkpoint is keyed by `prompt_index` and bundles per-domain state
//! (filesystem [`RewindPoint`], optional hunk delta, optional git HEAD/index);
//! restore reverts all enabled domains together.
use crate::handle::WorkspaceHandle;
use crate::session::WorkspaceSession;
use crate::session::file_state::{FileRewindResponse, RewindPoint, rewind_files};
use crate::session::git;
use kigi_hunk_tracker::{HunkId, HunkTrackerSnapshot, HunkTurnDelta};
use kigi_tool_protocol::turn_hook::TurnHookOutcome;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
/// A turn/prompt boundary routed through [`WorkspaceHandle::on_turn_boundary`].
///
/// `prompt_index` selects the origin and keeps the two effect sets disjoint:
/// - `None` — a turn hook (`on_before_turn`/`on_after_turn`).
/// - `Some(idx)` — a rewind RPC arm (`begin_prompt`/`end_prompt`).
pub(crate) enum TurnBoundary {
/// Turn start.
Start {
prompt_index: Option<usize>,
turn_number: u64,
},
/// Turn end.
End {
prompt_index: Option<usize>,
turn_number: u64,
duration_ms: u64,
outcome: TurnHookOutcome,
},
}
impl TurnBoundary {
pub(crate) fn turn_start(turn_number: u64) -> Self {
Self::Start {
prompt_index: None,
turn_number,
}
}
/// Turn-hook end (from `on_after_turn`): activity bookkeeping (+ optional
/// rewind finalize under `workspace_rewind_all_outcomes`).
pub(crate) fn turn_end(turn_number: u64, duration_ms: u64, outcome: TurnHookOutcome) -> Self {
Self::End {
prompt_index: None,
turn_number,
duration_ms,
outcome,
}
}
/// Rewind begin (from the `begin_prompt` RPC arm): FS-rewind only.
///
/// The RPC carries no turn metadata; `turn ≈ prompt`, so `turn_number`
/// mirrors `prompt_index` and the dispatcher ignores it on this path.
pub(crate) fn rewind_begin(prompt_index: usize) -> Self {
Self::Start {
prompt_index: Some(prompt_index),
turn_number: prompt_index as u64,
}
}
/// Rewind finalize (from the `end_prompt` RPC arm): FS-rewind only.
///
/// As with [`Self::rewind_begin`], the turn-hook fields are inert on this
/// path and are never read by the dispatcher.
pub(crate) fn rewind_finalize(prompt_index: usize) -> Self {
Self::End {
prompt_index: Some(prompt_index),
turn_number: prompt_index as u64,
duration_ms: 0,
outcome: TurnHookOutcome::Completed,
}
}
}
/// A per-prompt rewind checkpoint: the FS rewind point bundled with the
/// incremental hunk-tracker delta for the same `prompt_index`. Assembled on
/// demand by [`WorkspaceSession::get_checkpoint`]; serialized to disk by the
/// [`CheckpointStore`](crate::session::checkpoint_store::CheckpointStore).
///
/// Optional domain fields use `#[serde(default)]` so the schema stays additive:
/// a blob written before a later field existed still deserializes (field `None`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RewindCheckpoint {
/// The prompt this checkpoint belongs to.
pub prompt_index: usize,
/// Filesystem before/after snapshots for the prompt.
pub fs: RewindPoint,
/// Incremental hunk delta. `None` when capture was off or the turn touched
/// no tracked files.
#[serde(default)]
pub hunks: Option<HunkTurnDelta>,
}
/// Resolve `workspace_rewind_hunks` from `KIGI_WORKSPACE_REWIND_HUNKS` (default off).
pub(crate) fn rewind_hunks_enabled() -> bool {
kigi_config::env_bool("KIGI_WORKSPACE_REWIND_HUNKS").unwrap_or(false)
}
/// Resolve `workspace_rewind_durable` from `KIGI_WORKSPACE_REWIND_DURABLE`
/// (default off). Off ⇒ the legacy in-memory-only path with no disk I/O.
pub(crate) fn rewind_durable_enabled() -> bool {
kigi_config::env_bool("KIGI_WORKSPACE_REWIND_DURABLE").unwrap_or(false)
}
impl WorkspaceSession {
/// Capture the hunk delta for `prompt_index` into the in-memory store.
/// Last-write-wins, so repeated finalizes (e.g. `ForceContinue`) are
/// idempotent. Empty deltas are skipped to avoid a stale `turn_index` entry.
/// Returns `true` when a delta was stored, so callers count a hunk capture.
pub(crate) async fn capture_hunk_delta(&self, prompt_index: usize) -> bool {
let Some(delta) = self.hunk_tracker.snapshot_turn_delta(prompt_index).await else {
return false;
};
if delta.file_states.is_empty() {
return false;
}
self.hunk_checkpoints
.lock()
.await
.insert(prompt_index, delta);
true
}
/// Record the metrics shared by both finalize paths: FS capture + finalize
/// counters and FS capture duration, plus the git-domain capture when git
/// state was recorded at begin. Git is captured at begin (outcome unknown),
/// so it's *counted* here where `outcome` is known. Hunk is recorded by the
/// caller on whichever finalize path captured it.
async fn record_finalize_metrics(
&self,
prompt_index: usize,
outcome: TurnHookOutcome,
fs_capture_seconds: f64,
) {
crate::handle::record_fs_finalize(outcome, fs_capture_seconds);
if git::git_rewind_enabled() && self.git_checkpoints().contains(prompt_index).await {
crate::handle::record_rewind_capture(crate::handle::RewindDomain::Git, outcome);
}
}
/// Re-seed the hunk tracker to the **start** of `target_prompt_index` by
/// composing stored deltas for prompts `< target` (ascending; last write per
/// path wins) and dropping deltas `>= target`, mirroring the FS rewind.
///
/// No-op when the store is empty, or holds only deltas `>= target` with
/// `target > 0` (flag enabled mid-session; start-of-target can't be rebuilt,
/// so uncaptured live hunks aren't wiped). `target == 0` re-seeds to empty.
/// Composed `turn_index` ids are pruned to those surviving in the snapshots.
pub(crate) async fn restore_hunk_checkpoints(&self, target_prompt_index: usize) {
let mut store = self.hunk_checkpoints.lock().await;
if store.is_empty() {
return;
}
let mut prompts: Vec<usize> = store
.keys()
.copied()
.filter(|&idx| idx < target_prompt_index)
.collect();
if prompts.is_empty() && target_prompt_index > 0 {
return;
}
prompts.sort_unstable();
let mut file_states: HashMap<std::path::PathBuf, kigi_hunk_tracker::FileHunkStateSnapshot> =
HashMap::new();
let mut turn_index: HashMap<usize, HashSet<HunkId>> = HashMap::new();
for idx in prompts {
let delta = &store[&idx];
for (path, snap) in &delta.file_states {
file_states.insert(path.clone(), snap.clone());
}
turn_index.insert(idx, delta.hunk_ids.clone());
}
let present: HashSet<HunkId> = file_states
.values()
.flat_map(|state| state.hunks.iter().map(|h| h.id.clone()))
.collect();
turn_index.retain(|_, ids| {
ids.retain(|id| present.contains(id));
!ids.is_empty()
});
let session_stats = self.hunk_tracker.get_session_summary().await.stats;
self.hunk_tracker.restore_state(HunkTrackerSnapshot {
file_states,
turn_index,
session_stats,
});
store.retain(|&idx, _| idx < target_prompt_index);
}
/// Assemble the [`RewindCheckpoint`] for `prompt_index` from the live FS
/// rewind point and stored hunk delta. `None` when no FS rewind point exists.
pub async fn get_checkpoint(&self, prompt_index: usize) -> Option<RewindCheckpoint> {
let fs = self
.file_state_tracker
.get_rewind_point(prompt_index)
.await?;
let hunks = self
.hunk_checkpoints
.lock()
.await
.get(&prompt_index)
.cloned();
Some(RewindCheckpoint {
prompt_index,
fs,
hunks,
})
}
/// Assemble the checkpoint for `prompt_index` and write it through the durable
/// [`CheckpointStore`](crate::session::checkpoint_store::CheckpointStore).
/// Last-write-wins (idempotent across repeated finalizes); no-op when no
/// checkpoint exists. Mirror only — restore stays in-process.
pub(crate) async fn persist_checkpoint(&self, prompt_index: usize) {
let Some(checkpoint) = self.get_checkpoint(prompt_index).await else {
return;
};
self.checkpoint_store.persist(checkpoint).await;
}
/// Drop persisted checkpoints `>= target_prompt_index` (cache + disk),
/// mirroring the FS/hunk truncation so the on-disk mirror stays consistent.
pub(crate) async fn truncate_checkpoint_store(&self, target_prompt_index: usize) {
self.checkpoint_store
.truncate_from(target_prompt_index)
.await;
}
}
impl WorkspaceHandle {
/// Single fan-out for turn/prompt boundaries.
///
/// Keyed on `prompt_index`: turn hooks (`None`) drive activity; rewind RPC
/// arms (`Some`) drive rewind capture (FS, plus git/hunks when their flags
/// are on). `workspace_rewind_all_outcomes` also finalizes the open FS
/// checkpoint on non-`Completed` turn-ends (gap #2).
pub(crate) async fn on_turn_boundary(&self, session_id: &str, boundary: TurnBoundary) {
match boundary {
TurnBoundary::Start {
prompt_index: Some(idx),
..
} => {
if let Some(session) = self.session(session_id) {
session.file_state_tracker().begin_prompt(idx).await;
if git::git_rewind_enabled() {
let started = std::time::Instant::now();
if let Some(state) = git::capture_git_state(session.cwd()).await {
session.git_checkpoints().record(idx, state).await;
crate::handle::observe_rewind_capture_duration(
crate::handle::RewindDomain::Git,
started.elapsed().as_secs_f64(),
);
}
}
}
}
TurnBoundary::Start {
prompt_index: None,
turn_number,
} => {
self.shared
.activity_tracker
.turn_started(session_id, turn_number);
}
TurnBoundary::End {
prompt_index: Some(idx),
outcome,
..
} => {
if let Some(session) = self.session(session_id) {
let tracker = session.file_state_tracker();
let finalized = tracker.current_prompt_index().await == Some(idx);
let fs_started = std::time::Instant::now();
tracker.end_prompt(session.async_fs(), idx).await;
let fs_elapsed = fs_started.elapsed().as_secs_f64();
let mut hunk_captured = false;
let mut hunk_capture_secs = 0.0_f64;
if rewind_hunks_enabled() {
let hunk_started = std::time::Instant::now();
hunk_captured = session.capture_hunk_delta(idx).await;
hunk_capture_secs = hunk_started.elapsed().as_secs_f64();
}
if rewind_durable_enabled() {
session.persist_checkpoint(idx).await;
}
if finalized {
session
.record_finalize_metrics(idx, outcome, fs_elapsed)
.await;
if hunk_captured {
crate::handle::observe_rewind_capture_duration(
crate::handle::RewindDomain::Hunk,
hunk_capture_secs,
);
crate::handle::record_rewind_capture(
crate::handle::RewindDomain::Hunk,
outcome,
);
}
}
}
}
TurnBoundary::End {
prompt_index: None,
turn_number,
duration_ms,
outcome,
} => {
self.shared
.activity_tracker
.turn_completed(session_id, turn_number, duration_ms);
if self.shared.workspace_rewind_all_outcomes
&& outcome != TurnHookOutcome::Completed
&& let Some(session) = self.session(session_id)
{
let tracker = session.file_state_tracker();
if let Some(idx) = tracker.current_prompt_index().await {
let fs_started = std::time::Instant::now();
tracker.end_prompt(session.async_fs(), idx).await;
let fs_elapsed = fs_started.elapsed().as_secs_f64();
let mut hunk_captured = false;
let mut hunk_capture_secs = 0.0_f64;
if rewind_hunks_enabled() {
let hunk_started = std::time::Instant::now();
hunk_captured = session.capture_hunk_delta(idx).await;
hunk_capture_secs = hunk_started.elapsed().as_secs_f64();
}
if rewind_durable_enabled() {
session.persist_checkpoint(idx).await;
}
session
.record_finalize_metrics(idx, outcome, fs_elapsed)
.await;
if hunk_captured {
crate::handle::observe_rewind_capture_duration(
crate::handle::RewindDomain::Hunk,
hunk_capture_secs,
);
crate::handle::record_rewind_capture(
crate::handle::RewindDomain::Hunk,
outcome,
);
}
crate::handle::record_non_completed_finalize_canary(outcome);
}
}
}
}
}
/// User-initiated restore (`workspace.rewind_to` RPC): restore every enabled
/// domain to before `target_prompt_index`. Ordering matters: git's soft
/// restore (stash + `reset --soft` + unstage, behind `workspace_rewind_git`)
/// runs first so its stash-or-abort guard sees live state; then the FS is
/// reverted; then — only on FS success — git paths are re-staged against the
/// reverted tree and git/hunk checkpoints `>= target` are dropped. A failed
/// `rewind_files` keeps all domains for retry. Missing session yields a failed
/// response, not a panic.
pub(crate) async fn rewind_to(
&self,
session_id: &str,
target_prompt_index: usize,
) -> FileRewindResponse {
let Some(session) = self.session(session_id) else {
return FileRewindResponse {
success: false,
target_prompt_index,
reverted_files: Vec::new(),
clean_files: Vec::new(),
conflicts: Vec::new(),
error: Some(format!("workspace session not found: {session_id}")),
};
};
let git_restore = self
.restore_git_checkpoint(session_id, target_prompt_index)
.await;
if let Some((git_outcome, _)) = git_restore.as_ref() {
if !git_outcome.restored {
crate::handle::record_rewind_restore(crate::handle::RewindDomain::Git, false);
tracing::warn!(
session_id, target_prompt_index, reason = ? git_outcome
.aborted_reason, stash_ref = ? git_outcome.stash_ref,
"rewind_to: git domain not restored; filesystem still reverted (partial rewind)"
);
} else if let Some(stash_ref) = &git_outcome.stash_ref {
tracing::info!(
session_id, stash_ref = % stash_ref,
"rewind_to: git domain restored; pre-rewind changes saved to a stash"
);
}
}
let response = rewind_files(
session.file_state_tracker(),
session.async_fs(),
target_prompt_index,
)
.await;
crate::handle::record_rewind_restore(crate::handle::RewindDomain::Fs, response.success);
if response.success {
if let Some((git_outcome, git_state)) = git_restore.as_ref()
&& git_outcome.restored
{
let restaged = git::restage_git_paths(session.cwd(), git_state, session_id).await;
crate::handle::record_rewind_restore(
crate::handle::RewindDomain::Git,
git_outcome.index_reset && restaged,
);
session
.git_checkpoints()
.truncate_from(target_prompt_index)
.await;
}
if git::git_rewind_enabled() && git_restore.is_none() {
tracing::warn!(
session_id,
target_prompt_index,
"rewind_to: git domain not rewound (no git checkpoint at or before \
target); HEAD left untouched and may not match the reverted tree \
(partial rewind); git checkpoints retained to stay coherent with HEAD"
);
}
if rewind_hunks_enabled() {
session.restore_hunk_checkpoints(target_prompt_index).await;
crate::handle::record_rewind_restore(crate::handle::RewindDomain::Hunk, true);
}
}
if response.success && rewind_durable_enabled() {
session.truncate_checkpoint_store(target_prompt_index).await;
}
response
}
/// Soft-restore the git domain at `target_prompt_index` (phase 1 only: stash +
/// `reset --soft` + unstage; turn-local commits preserved). Returns the
/// [`GitRestoreOutcome`](git::GitRestoreOutcome) and captured
/// [`GitStateRef`](git::GitStateRef) so the caller can, after the FS revert
/// succeeds, re-stage paths and drop checkpoints `>= target` — deferred so a
/// failed FS revert retains them for retry.
///
/// Checkpoint selection prefers the state captured exactly at the target, but
/// falls back to the nearest earlier checkpoint (greatest index `<= target`)
/// when none was captured at the target itself — e.g. git-rewind was enabled
/// mid-session so the target predates capture, or capture was skipped for that
/// prompt. This lands HEAD on the closest known-good git state instead of
/// leaving it post-turn. `None` when disabled, no session, or no git state
/// captured at or before the target (caller then degrades explicitly).
pub(crate) async fn restore_git_checkpoint(
&self,
session_id: &str,
target_prompt_index: usize,
) -> Option<(git::GitRestoreOutcome, git::GitStateRef)> {
if !git::git_rewind_enabled() {
return None;
}
let session = self.session(session_id)?;
let store = session.git_checkpoints();
let (checkpoint_index, state) = match store.get_at_or_before(target_prompt_index).await {
Some(found) => found,
None => {
tracing::debug!(
session_id,
target_prompt_index,
"restore_git_checkpoint: no git state captured at or before target; skipping"
);
return None;
}
};
if checkpoint_index != target_prompt_index {
tracing::info!(
session_id,
target_prompt_index,
checkpoint_index,
"restore_git_checkpoint: no checkpoint at target; soft-restoring to the \
nearest earlier git checkpoint (closest known-good HEAD)"
);
}
let outcome = git::soft_restore_git_state(session.cwd(), &state, session_id).await;
Some((outcome, state))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::tests::{make_handle, make_handle_with_rewind_all_outcomes};
#[tokio::test]
async fn start_with_prompt_index_begins_and_end_finalizes_fs_rewind() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
handle
.on_turn_boundary("main", TurnBoundary::rewind_begin(3))
.await;
let tracker = session.file_state_tracker();
assert_eq!(tracker.current_prompt_index().await, Some(3));
assert!(
tracker.get_rewind_point(3).await.is_some(),
"begin should create a rewind point for the prompt"
);
tracker
.add_before_snapshot_for_prompt(3, &cwd.join("a.txt"), &cwd, Some("v1".to_owned()))
.await;
handle
.on_turn_boundary("main", TurnBoundary::rewind_finalize(3))
.await;
assert_eq!(
tracker.current_prompt_index().await,
None,
"finalize (end_prompt) clears the current prompt index"
);
let point = tracker
.get_rewind_point(3)
.await
.expect("rewind point still present after finalize");
assert!(
!point.after_snapshots.is_empty(),
"finalize should capture after-snapshots for touched files"
);
}
#[tokio::test]
async fn turn_hook_start_does_not_touch_fs_rewind() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
handle
.on_turn_boundary("main", TurnBoundary::turn_start(7))
.await;
let tracker = session.file_state_tracker();
assert!(
tracker.get_rewind_points().await.is_empty(),
"turn-hook boundary must not create rewind points"
);
assert_eq!(tracker.current_prompt_index().await, None);
}
/// Open a checkpoint and record one touched file so finalize has an after-snapshot.
async fn open_checkpoint_with_touched_file(handle: &WorkspaceHandle, prompt_index: usize) {
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
handle
.on_turn_boundary("main", TurnBoundary::rewind_begin(prompt_index))
.await;
session
.file_state_tracker()
.add_before_snapshot_for_prompt(
prompt_index,
&cwd.join("a.txt"),
&cwd,
Some("v1".to_owned()),
)
.await;
}
#[tokio::test]
async fn non_completed_turn_end_finalizes_open_fs_rewind_when_flag_on() {
let handle = make_handle_with_rewind_all_outcomes(true);
open_checkpoint_with_touched_file(&handle, 5).await;
let session = handle.session("main").expect("main session exists");
let tracker = session.file_state_tracker();
assert_eq!(tracker.current_prompt_index().await, Some(5));
handle
.on_turn_boundary("main", TurnBoundary::turn_end(5, 0, TurnHookOutcome::Error))
.await;
assert_eq!(
tracker.current_prompt_index().await,
None,
"non-Completed turn-end should finalize (clear) the open prompt"
);
let point = tracker
.get_rewind_point(5)
.await
.expect("rewind point present");
assert!(
!point.after_snapshots.is_empty(),
"finalize should capture after-snapshots for touched files"
);
}
#[tokio::test]
async fn non_completed_turn_end_does_not_finalize_when_flag_off() {
let handle = make_handle();
open_checkpoint_with_touched_file(&handle, 5).await;
let session = handle.session("main").expect("main session exists");
let tracker = session.file_state_tracker();
handle
.on_turn_boundary("main", TurnBoundary::turn_end(5, 0, TurnHookOutcome::Error))
.await;
assert_eq!(
tracker.current_prompt_index().await,
Some(5),
"with the flag off the turn-hook path must leave the checkpoint open"
);
let point = tracker
.get_rewind_point(5)
.await
.expect("rewind point present");
assert!(
point.after_snapshots.is_empty(),
"no after-snapshots should be captured when the flag is off"
);
}
#[tokio::test]
async fn completed_turn_end_hook_does_not_finalize_even_when_flag_on() {
let handle = make_handle_with_rewind_all_outcomes(true);
open_checkpoint_with_touched_file(&handle, 5).await;
let session = handle.session("main").expect("main session exists");
let tracker = session.file_state_tracker();
handle
.on_turn_boundary(
"main",
TurnBoundary::turn_end(5, 0, TurnHookOutcome::Completed),
)
.await;
assert_eq!(
tracker.current_prompt_index().await,
Some(5),
"Completed turn-ends are finalized by the RPC path, not the hook path"
);
}
/// The non-Completed canary advances when a non-`Completed` turn-end finalizes
/// an open FS checkpoint (flag on). Counters are monotonic, so `after > before` is robust.
#[tokio::test]
async fn canary_counts_non_completed_finalize() {
let label = crate::handle::rewind_outcome_label(TurnHookOutcome::Error);
let before = crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL
.with_label_values(&[label])
.get();
let handle = make_handle_with_rewind_all_outcomes(true);
open_checkpoint_with_touched_file(&handle, 11).await;
handle
.on_turn_boundary(
"main",
TurnBoundary::turn_end(11, 0, TurnHookOutcome::Error),
)
.await;
let after = crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL
.with_label_values(&[label])
.get();
assert!(
after > before,
"canary must advance on a non-Completed finalize (before={before}, after={after})"
);
}
/// A `Completed` turn-end never feeds the canary: the `completed` label must
/// stay zero, guarding the `outcome != Completed` gate.
#[tokio::test]
async fn canary_never_labeled_completed() {
let handle = make_handle_with_rewind_all_outcomes(true);
open_checkpoint_with_touched_file(&handle, 12).await;
handle
.on_turn_boundary(
"main",
TurnBoundary::turn_end(12, 0, TurnHookOutcome::Completed),
)
.await;
assert_eq!(
crate::handle::REWIND_NON_COMPLETED_FINALIZE_TOTAL
.with_label_values(&[crate::handle::rewind_outcome_label(
TurnHookOutcome::Completed
)])
.get(),
0,
"Completed turns must never increment the non-Completed finalize canary"
);
}
/// Capture two turns, rewind: earlier turn survives; target-and-later are
/// dropped and truncated.
#[tokio::test]
async fn capture_then_restore_round_trips_turn_delta() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
let hunks = session.hunk_tracker();
hunks.record_agent_write(cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None);
hunks.record_agent_write(cwd.join("b.rs"), "fn b() {}\n".to_owned(), 1, None);
session.capture_hunk_delta(0).await;
session.capture_hunk_delta(1).await;
assert!(!hunks.get_turn_hunks(0).await.is_empty());
assert!(!hunks.get_turn_hunks(1).await.is_empty());
session.restore_hunk_checkpoints(1).await;
assert!(
hunks.get_turn_hunks(1).await.is_empty(),
"rewind must drop the rewound turn's hunks"
);
assert!(
!hunks.get_turn_hunks(0).await.is_empty(),
"rewind must keep turns before the target"
);
let tracked = hunks.get_all_tracked_paths().await;
assert!(
tracked.contains(&cwd.join("a.rs")),
"kept turn's file remains"
);
assert!(
!tracked.contains(&cwd.join("b.rs")),
"rewound turn's file is gone"
);
let store = session.hunk_checkpoints.lock().await;
assert!(store.contains_key(&0), "kept delta remains in the store");
assert!(!store.contains_key(&1), "rewound delta is dropped");
}
/// `get_checkpoint` assembles both domains: FS rewind point + stored hunk delta.
#[tokio::test]
async fn get_checkpoint_bundles_fs_point_and_hunk_delta() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
let tracker = session.file_state_tracker();
tracker.begin_prompt(0).await;
tracker
.add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned()))
.await;
session.hunk_tracker().record_agent_write(
cwd.join("a.rs"),
"fn a() {}\n".to_owned(),
0,
None,
);
session.capture_hunk_delta(0).await;
let checkpoint = session.get_checkpoint(0).await.expect("checkpoint exists");
assert_eq!(checkpoint.prompt_index, 0);
assert!(
!checkpoint.fs.file_snapshots.is_empty(),
"fs side carries the before-snapshot"
);
let delta = checkpoint.hunks.expect("hunk delta captured");
assert!(delta.file_states.contains_key(&cwd.join("a.rs")));
assert!(session.get_checkpoint(5).await.is_none());
}
/// Flag off (default): finalize never captures a hunk delta.
#[tokio::test]
async fn turn_end_with_flag_off_skips_hunk_capture() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
session.hunk_tracker().record_agent_write(
cwd.join("a.rs"),
"fn a() {}\n".to_owned(),
0,
None,
);
assert!(
!rewind_hunks_enabled(),
"flag must default off for the legacy path"
);
handle
.on_turn_boundary("main", TurnBoundary::rewind_finalize(0))
.await;
assert!(
session.hunk_checkpoints.lock().await.is_empty(),
"flag-off finalize must not store hunk deltas (legacy default)"
);
}
/// Mid-session enable with no deltas before a non-zero target: restore must
/// not wipe live hunks.
#[tokio::test]
async fn restore_with_no_deltas_before_target_does_not_wipe() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
let hunks = session.hunk_tracker();
hunks.record_agent_write(cwd.join("early.rs"), "fn e() {}\n".to_owned(), 1, None);
hunks.record_agent_write(cwd.join("late.rs"), "fn l() {}\n".to_owned(), 5, None);
session.capture_hunk_delta(5).await;
session.restore_hunk_checkpoints(3).await;
let tracked = hunks.get_all_tracked_paths().await;
assert!(
tracked.contains(&cwd.join("early.rs")),
"uncaptured live hunks must not be wiped when nothing is captured before the target"
);
assert!(
session.hunk_checkpoints.lock().await.contains_key(&5),
"the store is left untouched when restore cannot reconstruct"
);
}
/// Rewind to 0: re-seed to the empty start-of-session state.
#[tokio::test]
async fn restore_to_zero_clears_all_hunk_state() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
let hunks = session.hunk_tracker();
hunks.record_agent_write(cwd.join("a.rs"), "fn a() {}\n".to_owned(), 0, None);
session.capture_hunk_delta(0).await;
assert!(!hunks.get_all_tracked_paths().await.is_empty());
session.restore_hunk_checkpoints(0).await;
assert!(
hunks.get_all_tracked_paths().await.is_empty(),
"rewind to 0 reconstructs the empty start-of-session hunk state"
);
assert!(
session.hunk_checkpoints.lock().await.is_empty(),
"rewind to 0 truncates the entire delta store"
);
}
/// `workspace_rewind_git` default (OFF): capture must not touch git.
#[tokio::test]
async fn git_capture_is_noop_when_flag_disabled_by_default() {
assert!(
!git::git_rewind_enabled(),
"workspace_rewind_git must default OFF"
);
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
handle
.on_turn_boundary("main", TurnBoundary::rewind_begin(2))
.await;
assert!(
session.git_checkpoints().get(2).await.is_none(),
"git state must not be captured while workspace_rewind_git is disabled"
);
assert!(
session
.file_state_tracker()
.get_rewind_point(2)
.await
.is_some(),
"FS-rewind capture must be unaffected by the git flag"
);
}
/// A fully-populated checkpoint (FS + hunk delta) round-trips through JSON:
/// `Value` is stable across encode→decode→encode and both payloads survive.
/// `Hunk.selected` is `#[serde(skip)]` (transient), so it decodes to default.
#[tokio::test]
async fn rewind_checkpoint_round_trips_through_json() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
let tracker = session.file_state_tracker();
tracker.begin_prompt(0).await;
tracker
.add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned()))
.await;
tracker.end_prompt(session.async_fs(), 0).await;
session.hunk_tracker().record_agent_write(
cwd.join("a.rs"),
"fn a() {}\n".to_owned(),
0,
None,
);
session.capture_hunk_delta(0).await;
let original = session.get_checkpoint(0).await.expect("checkpoint exists");
assert!(
original.hunks.is_some(),
"precondition: hunk delta captured, so serde covers all populated domains"
);
let encoded = serde_json::to_value(&original).expect("serialize");
let decoded: RewindCheckpoint =
serde_json::from_value(encoded.clone()).expect("deserialize");
let re_encoded = serde_json::to_value(&decoded).expect("re-serialize");
assert_eq!(
encoded, re_encoded,
"RewindCheckpoint must round-trip through JSON unchanged"
);
assert_eq!(decoded.prompt_index, 0);
assert_eq!(
decoded.fs.file_snapshots.len(),
1,
"fs before-snapshot survives the round-trip"
);
let delta = decoded.hunks.expect("hunk delta survives round-trip");
assert_eq!(delta.prompt_index, 0);
let snapshot = delta
.file_states
.get(&cwd.join("a.rs"))
.expect("hunk delta's touched file survives");
let hunk = snapshot.hunks.first().expect("the file's hunk survives");
assert!(
hunk.new_text.contains("fn a()"),
"hunk new_text payload survives the round-trip, got {:?}",
hunk.new_text
);
assert!(!hunk.selected, "transient `selected` is not persisted");
}
/// Optional domain fields are additive: a checkpoint round-trips with
/// `hunks: None`, and JSON omitting the optional field still deserializes.
#[tokio::test]
async fn checkpoint_serde_tolerates_missing_optional_fields() {
let cp = RewindCheckpoint {
prompt_index: 2,
fs: RewindPoint::new(2),
hunks: None,
};
let encoded = serde_json::to_value(&cp).expect("serialize");
let decoded: RewindCheckpoint = serde_json::from_value(encoded).expect("deserialize");
assert_eq!(decoded.prompt_index, 2);
assert!(decoded.hunks.is_none());
let json = r#"{
"prompt_index": 2,
"fs": {
"prompt_index": 2,
"created_at": "2024-01-01T00:00:00Z",
"file_snapshots": {},
"after_snapshots": {}
}
}"#;
let from_legacy: RewindCheckpoint = serde_json::from_str(json).expect("deserialize legacy");
assert_eq!(from_legacy.prompt_index, 2);
assert!(
from_legacy.hunks.is_none(),
"a missing optional field defaults to None"
);
}
/// Durable flag off (default): finalize does no disk I/O — the on-disk store
/// directory is never created.
#[tokio::test]
async fn turn_end_with_durable_flag_off_writes_nothing_to_disk() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
assert!(
!rewind_durable_enabled(),
"durable flag must default off for the legacy path"
);
handle
.on_turn_boundary("main", TurnBoundary::rewind_begin(0))
.await;
session
.file_state_tracker()
.add_before_snapshot_for_prompt(0, &cwd.join("a.txt"), &cwd, Some("v".to_owned()))
.await;
handle
.on_turn_boundary("main", TurnBoundary::rewind_finalize(0))
.await;
let store_root = cwd.join(".kigi").join("rewind-checkpoints");
assert!(
!store_root.exists(),
"flag-off finalize must not touch disk (legacy default)"
);
}
/// `persist_checkpoint` writes the assembled checkpoint through the session's
/// durable store (called directly to bypass the flag); retrievable with both domains.
#[tokio::test]
async fn persist_checkpoint_serializes_through_session_store() {
let handle = make_handle();
let session = handle.session("main").expect("main session exists");
let cwd = session.cwd().to_path_buf();
let tracker = session.file_state_tracker();
tracker.begin_prompt(0).await;
tracker
.add_before_snapshot_for_prompt(0, &cwd.join("a.rs"), &cwd, Some("v0".to_owned()))
.await;
tracker.end_prompt(session.async_fs(), 0).await;
session.hunk_tracker().record_agent_write(
cwd.join("a.rs"),
"fn a() {}\n".to_owned(),
0,
None,
);
session.capture_hunk_delta(0).await;
session.persist_checkpoint(0).await;
let store_root = cwd.join(".kigi").join("rewind-checkpoints");
assert!(store_root.exists(), "persist creates the durable store dir");
let stored = session
.checkpoint_store
.get(0)
.await
.expect("checkpoint persisted");
assert_eq!(stored.prompt_index, 0);
assert!(
stored.hunks.is_some(),
"hunk delta is bundled into the blob"
);
assert_eq!(stored.fs.file_snapshots.len(), 1);
}
/// Full rewind lifecycle against a disabled (`noop()`) hunk tracker — the
/// operating mode when the user sets `hunk_tracker_mode = off`. Capture
/// must report "nothing stored" and restore must be a clean no-op, while
/// the FS-rewind half keeps working (hunks simply absent from the blob).
#[tokio::test]
async fn rewind_lifecycle_is_clean_noop_with_disabled_hunk_tracker() {
let handle = make_handle();
handle.drop_session("main", "main").expect("drop main");
let session = handle
.create_session_with_tracker_and_viewer_ctx(
"main",
handle.root_cwd().unwrap(),
kigi_hunk_tracker::HunkTrackerHandle::noop(),
None,
crate::capability::CapabilityMode::All,
None,
false,
)
.expect("create session with noop tracker");
let cwd = session.cwd().to_path_buf();
let file = cwd.join("a.rs");
let tracker = session.file_state_tracker();
tracker.begin_prompt(0).await;
tracker
.add_before_snapshot_for_prompt(0, &file, &cwd, Some("v0\n".to_owned()))
.await;
session
.async_fs()
.write_file(&file, b"v1\n")
.await
.expect("write v1");
tracker.end_prompt(session.async_fs(), 0).await;
session
.hunk_tracker()
.record_agent_write(file.clone(), "v1\n".to_owned(), 0, None);
assert!(
!session.capture_hunk_delta(0).await,
"capture_hunk_delta must return false under a disabled hunk tracker"
);
session.restore_hunk_checkpoints(0).await;
session.persist_checkpoint(0).await;
let stored = session
.checkpoint_store
.get(0)
.await
.expect("FS checkpoint persisted even with the tracker disabled");
assert_eq!(stored.prompt_index, 0);
assert!(
stored.hunks.is_none(),
"no hunk delta is bundled when the tracker is disabled"
);
assert_eq!(stored.fs.file_snapshots.len(), 1);
let resp = handle.rewind_to("main", 0).await;
assert!(resp.success, "FS rewind must succeed: {:?}", resp.error);
assert!(
resp.reverted_files.iter().any(|f| f.ends_with("a.rs")),
"a.rs must be among the reverted files: {:?}",
resp.reverted_files
);
let restored = session
.async_fs()
.try_read_to_string(&file)
.await
.expect("read a.rs");
assert_eq!(
restored.as_deref(),
Some("v0\n"),
"file content must revert to its pre-prompt state"
);
}
}