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).
This commit is contained in:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,583 @@
|
||||
//! Rewind concern for `SessionActor`: rewind points, cross-compaction
|
||||
//! replay detection, and `handle_rewind`.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl SessionActor {
|
||||
pub(super) async fn close_rewind_window(&self) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.rewindable = false;
|
||||
}
|
||||
|
||||
/// Returns the `prompt_index → num_file_snapshots` map from the on-disk
|
||||
/// snapshot index (independent of the chat-state prompt index). The bridge
|
||||
/// joins these onto the server's rewind points.
|
||||
pub(super) async fn rewind_file_counts(&self) -> std::collections::HashMap<usize, usize> {
|
||||
self.file_state_tracker
|
||||
.get_rewind_point_metas()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|m| (m.prompt_index, m.num_file_snapshots))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get available rewind points for this session.
|
||||
///
|
||||
/// Every prompt is a checkpoint — the list always contains `[0, 1, ..., N-1]`
|
||||
/// where N is the current prompt_index. File snapshots may or may not exist
|
||||
/// for each checkpoint (indicated by `has_file_changes`).
|
||||
pub(super) async fn get_rewind_points(&self) -> RewindPointsResponse {
|
||||
// Metadata only — don't materialize the (huge) file-content snapshots
|
||||
// just to render the picker.
|
||||
let file_metas = self.file_state_tracker.get_rewind_point_metas().await;
|
||||
|
||||
// Query prompt state from the chat state actor.
|
||||
let snapshot = self.chat_state_handle.snapshot().await;
|
||||
let (prompts, current_prompt_index) = match snapshot {
|
||||
Some(ref s) => (s.prompt_texts.clone(), s.prompt_index),
|
||||
None => (vec![], 0),
|
||||
};
|
||||
|
||||
// Build a lookup of which prompt indices have file snapshots.
|
||||
let file_meta_map: std::collections::HashMap<
|
||||
usize,
|
||||
&kigi_workspace::session::file_state::RewindPointMeta,
|
||||
> = file_metas.iter().map(|m| (m.prompt_index, m)).collect();
|
||||
|
||||
// Generate a rewind point for every prompt 0..current_prompt_index.
|
||||
let rewind_points = (0..current_prompt_index)
|
||||
.map(|idx| {
|
||||
let prompt_preview = prompts.get(idx).and_then(|text| {
|
||||
let clean_text = extract_user_query(text);
|
||||
let first_line = clean_text
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("");
|
||||
|
||||
if first_line.is_empty() {
|
||||
None
|
||||
} else if first_line.chars().count() > 60 {
|
||||
Some(format!("{}...", crate::util::truncate(first_line, 57)))
|
||||
} else {
|
||||
Some(first_line.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
let file_meta = file_meta_map.get(&idx);
|
||||
let num_file_snapshots = file_meta.map_or(0, |m| m.num_file_snapshots);
|
||||
let created_at = file_meta
|
||||
.map(|m| m.created_at.to_rfc3339())
|
||||
.unwrap_or_default();
|
||||
|
||||
RewindPointInfo {
|
||||
prompt_index: idx,
|
||||
created_at,
|
||||
num_file_snapshots,
|
||||
has_file_changes: num_file_snapshots > 0,
|
||||
prompt_preview,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
RewindPointsResponse { rewind_points }
|
||||
}
|
||||
|
||||
/// Load user prompts from `updates.jsonl` in chronological order.
|
||||
///
|
||||
/// Each `UserMessageChunk` sequence is merged into a single prompt string.
|
||||
/// `RewindMarker` entries truncate the list back to the marker's target so
|
||||
/// only prompts from the current timeline are returned.
|
||||
///
|
||||
/// Uses [`PromptExtractIterator`] which peeks at the `update.sessionUpdate`
|
||||
/// discriminant field without fully deserialising every notification. This
|
||||
/// avoids large `acp::SessionNotification` allocations for the many update
|
||||
/// types (tool calls, assistant chunks, etc.) that are irrelevant to prompt
|
||||
/// extraction.
|
||||
pub(super) fn load_user_prompts_from_updates(
|
||||
updates_path: &std::path::Path,
|
||||
) -> std::io::Result<Vec<String>> {
|
||||
use crate::session::storage::{PromptExtractIterator, collect_prompts_from_events};
|
||||
|
||||
let Some(iter) = PromptExtractIterator::open(updates_path)? else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
path = %updates_path.display(),
|
||||
"load_user_prompts_from_updates: starting selective scan"
|
||||
);
|
||||
|
||||
let prompts = collect_prompts_from_events(iter);
|
||||
|
||||
tracing::debug!(
|
||||
prompt_count = prompts.len(),
|
||||
"load_user_prompts_from_updates: done"
|
||||
);
|
||||
|
||||
Ok(prompts)
|
||||
}
|
||||
|
||||
/// Check whether rewinding to `target_index` needs replay because a
|
||||
/// compaction has occurred, meaning we need to replay `updates.jsonl`
|
||||
/// to reconstruct the conversation.
|
||||
///
|
||||
/// Always use replay when compaction has occurred, regardless of whether
|
||||
/// the target is before, at, or after the compaction point.
|
||||
///
|
||||
/// Post-compaction, the in-memory conversation has a different number of
|
||||
/// User messages than `prompt_index` implies (compaction collapses N+1
|
||||
/// user messages into ~3). `truncate_to_prompt_index` counts User items
|
||||
/// to find the cut point, so it produces wrong results for ALL
|
||||
/// post-compaction targets — not just at the boundary.
|
||||
///
|
||||
/// `replay_to_prompt` reads `updates.jsonl` from scratch and handles
|
||||
/// compaction checkpoints correctly, so it always produces the right
|
||||
/// conversation regardless of target position.
|
||||
async fn needs_compaction_replay(&self) -> bool {
|
||||
let last = self
|
||||
.chat_state_handle
|
||||
.snapshot()
|
||||
.await
|
||||
.and_then(|s| s.last_compaction_prompt_index);
|
||||
match last {
|
||||
Some(compaction_at) => {
|
||||
tracing::info!(
|
||||
compaction_at,
|
||||
"Compaction detected — using replay for rewind"
|
||||
);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a rewind request with mode support.
|
||||
///
|
||||
/// Semantics: "restore state before prompt N ran" — prompts 0..N-1 are kept.
|
||||
///
|
||||
/// Modes:
|
||||
/// - `All`: roll back both conversation and files (full time-travel)
|
||||
/// - `ConversationOnly`: roll back conversation, leave files untouched
|
||||
/// - `FilesOnly`: roll back files, leave conversation untouched
|
||||
pub(super) async fn handle_rewind(
|
||||
&self,
|
||||
request: RewindRequest,
|
||||
) -> anyhow::Result<RewindResponse> {
|
||||
// Track revert for feedback signals
|
||||
self.signals_handle().mark_reverted();
|
||||
|
||||
let target_index = request.target_prompt_index;
|
||||
let mode = request.mode;
|
||||
|
||||
// Validate: target must be less than current prompt_index. FilesOnly
|
||||
// reverts the on-disk snapshot index (bounded by `get_rewind_points`,
|
||||
// not the conversation), so it is exempt — the chat-state prompt index
|
||||
// is empty in bridge mode, where the conversation lives server-side.
|
||||
let current_prompt_index = self.chat_state_handle.get_prompt_index().await;
|
||||
if mode != RewindMode::FilesOnly && target_index >= current_prompt_index {
|
||||
return Ok(RewindResponse {
|
||||
success: false,
|
||||
target_prompt_index: target_index,
|
||||
mode,
|
||||
reverted_files: vec![],
|
||||
clean_files: vec![],
|
||||
conflicts: vec![],
|
||||
prompt_text: None,
|
||||
error: Some(format!(
|
||||
"Cannot rewind to prompt #{} — current prompt index is {}. \
|
||||
Valid targets: 0..{}",
|
||||
target_index,
|
||||
current_prompt_index,
|
||||
current_prompt_index.saturating_sub(1)
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Build file revert preview (for All and FilesOnly modes) ─────
|
||||
let mut clean_files = Vec::new();
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let wants_file_revert = matches!(mode, RewindMode::All | RewindMode::FilesOnly);
|
||||
let wants_conversation_rewind =
|
||||
matches!(mode, RewindMode::All | RewindMode::ConversationOnly);
|
||||
|
||||
// Collect files that would be reverted and detect conflicts.
|
||||
// This is read-only — no mutations happen here.
|
||||
let mut files_to_revert: std::collections::HashMap<
|
||||
kigi_workspace::session::file_state::FlexiblePath,
|
||||
Option<String>,
|
||||
> = std::collections::HashMap::new();
|
||||
|
||||
if wants_file_revert {
|
||||
let all_points = self.file_state_tracker.get_rewind_points().await;
|
||||
|
||||
for point in all_points.iter().filter(|p| p.prompt_index >= target_index) {
|
||||
for (path, before_snapshot) in &point.file_snapshots {
|
||||
// Only keep the earliest snapshot for each file
|
||||
files_to_revert
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| before_snapshot.content.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Build conflict/clean lists for the preview
|
||||
for path in files_to_revert.keys() {
|
||||
let current_content = self
|
||||
.tool_context
|
||||
.fs
|
||||
.try_read_to_string(path)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
// Find the latest after_snapshot for this file (what the agent
|
||||
// most recently left it as) for conflict detection.
|
||||
let after_content = all_points
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|p| p.after_snapshots.get(path))
|
||||
.and_then(|s| s.content.clone());
|
||||
|
||||
let is_clean = current_content == after_content;
|
||||
|
||||
if is_clean {
|
||||
clean_files.push(path.to_string());
|
||||
} else {
|
||||
let conflict_type = if current_content.is_none() && after_content.is_some() {
|
||||
"deleted_externally"
|
||||
} else if current_content.is_some() && after_content.is_none() {
|
||||
"created_externally"
|
||||
} else {
|
||||
"modified_externally"
|
||||
};
|
||||
conflicts.push(RewindConflictInfo {
|
||||
path: path.to_string(),
|
||||
conflict_type: conflict_type.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preview mode (force=false): pure dry run, no mutations ────
|
||||
// Return what WOULD happen so the TUI can show a confirmation
|
||||
// modal. Nothing is written, deleted, or truncated.
|
||||
if !request.force {
|
||||
let error = if !conflicts.is_empty() {
|
||||
Some("External modifications detected. Confirm to revert anyway.".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return Ok(RewindResponse {
|
||||
success: false,
|
||||
target_prompt_index: target_index,
|
||||
mode,
|
||||
reverted_files: vec![],
|
||||
clean_files,
|
||||
conflicts,
|
||||
prompt_text: None,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Commit mode (force=true): execute the rewind ─────────────
|
||||
|
||||
// Execute file revert
|
||||
let mut reverted_files = Vec::new();
|
||||
if wants_file_revert {
|
||||
for (rel_path, content) in files_to_revert {
|
||||
match &content {
|
||||
Some(data) => {
|
||||
if let Err(e) = self
|
||||
.tool_context
|
||||
.fs
|
||||
.write_file(&rel_path, data.as_bytes())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "Failed to restore file during rewind");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if self
|
||||
.tool_context
|
||||
.fs
|
||||
.exists(&rel_path)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
&& let Err(e) = self.tool_context.fs.delete_file(&rel_path).await
|
||||
{
|
||||
tracing::warn!(?e, "Failed to delete file during rewind");
|
||||
}
|
||||
}
|
||||
}
|
||||
reverted_files.push(rel_path.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Execute conversation rewind
|
||||
let mut prompt_text: Option<String> = None;
|
||||
if wants_conversation_rewind {
|
||||
let session_dir = crate::session::persistence::session_dir(&self.session_info);
|
||||
let updates_path = session_dir.join("updates.jsonl");
|
||||
|
||||
if let Some(snap) = self.chat_state_handle.snapshot().await {
|
||||
prompt_text = snap.prompt_texts.get(target_index).cloned();
|
||||
}
|
||||
|
||||
// Store for edit-and-retry detection in the next prompt() call
|
||||
if let Ok(mut pending) = self.rewind_pending_prompt.lock() {
|
||||
*pending = prompt_text.clone();
|
||||
}
|
||||
|
||||
// Check cross-compaction before rewinding.
|
||||
let needs_replay = self.needs_compaction_replay().await;
|
||||
|
||||
// Get conversation from the chat state actor for truncation logic.
|
||||
let mut conversation = self.chat_state_handle.get_conversation().await;
|
||||
|
||||
// Cross-compaction replay recomputes whether a compaction summary
|
||||
// survives; `None` keeps the existing marker (standard truncation).
|
||||
let mut replay_compaction_marker: Option<Option<usize>> = None;
|
||||
|
||||
if needs_replay {
|
||||
// Cross-compaction rewind: reconstruct conversation from updates.jsonl.
|
||||
// Run on the blocking pool since replay does synchronous file I/O
|
||||
// (reading checkpoint files + scanning updates.jsonl).
|
||||
let replay_updates = updates_path.clone();
|
||||
let replay_session_dir = session_dir.clone();
|
||||
let replay_target = target_index;
|
||||
let replay_result = tokio::task::spawn_blocking(move || {
|
||||
crate::session::helpers::replay::replay_to_prompt(
|
||||
&replay_updates,
|
||||
&replay_session_dir,
|
||||
replay_target,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("spawn_blocking panicked: {e}"))?;
|
||||
match replay_result {
|
||||
Ok(replay_result) => {
|
||||
tracing::info!(
|
||||
target_index,
|
||||
prompt_index_reached = replay_result.prompt_index_reached,
|
||||
conversation_len = replay_result.conversation.len(),
|
||||
"Cross-compaction rewind: conversation reconstructed via replay"
|
||||
);
|
||||
// The rebuilt conversation drops the summary unless a
|
||||
// checkpoint survived; carry the recomputed marker to
|
||||
// the snapshot restore so the stale value isn't reused.
|
||||
replay_compaction_marker = Some(replay_result.last_compaction_prompt_index);
|
||||
// The replay result may or may not include the session
|
||||
// preamble (System + User(user_info)):
|
||||
// - Checkpoint loaded (target >= compaction_at): the
|
||||
// checkpoint's compacted_history already has System +
|
||||
// User prefix. Use directly.
|
||||
// - Raw updates (target < compaction_at): replay only
|
||||
// accumulates user/agent turns from updates.jsonl.
|
||||
// Prepend System + original User(user_info) so the
|
||||
// model sees the same preamble it originally saw.
|
||||
if matches!(
|
||||
replay_result.conversation.first(),
|
||||
Some(ConversationItem::System(_))
|
||||
) {
|
||||
conversation = replay_result.conversation;
|
||||
} else {
|
||||
// Keep System (index 0). Replace User(user_info) at
|
||||
// index 1 with the original from the checkpoint if
|
||||
// available, otherwise keep the current one.
|
||||
if let Some(ui0) = replay_result.original_user_info {
|
||||
conversation.truncate(1); // keep System only
|
||||
conversation.push(ConversationItem::user(ui0));
|
||||
} else {
|
||||
conversation.truncate(2); // keep System + current user_info
|
||||
}
|
||||
conversation.extend(replay_result.conversation);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
?e,
|
||||
target_index,
|
||||
"Cross-compaction replay failed — rewind aborted"
|
||||
);
|
||||
// Do NOT fall back to truncation: the post-compaction
|
||||
// conversation has wrong user-message counts, and raw
|
||||
// replay without a checkpoint produces an oversized
|
||||
// conversation that will exceed the context window.
|
||||
// Return a clear error so the user can rewind to a
|
||||
// different (post-compaction) prompt instead.
|
||||
return Ok(RewindResponse {
|
||||
success: false,
|
||||
target_prompt_index: target_index,
|
||||
mode,
|
||||
reverted_files: vec![],
|
||||
clean_files: vec![],
|
||||
conflicts: vec![],
|
||||
prompt_text: None,
|
||||
error: Some(format!(
|
||||
"Cannot rewind to prompt #{} — compaction checkpoint data is \
|
||||
unavailable ({e}). Try rewinding to a prompt after the \
|
||||
compaction point instead.",
|
||||
target_index,
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard rewind: truncate in-memory conversation. "Rewind
|
||||
// to N" = restore state before prompt N ran, keeping 0..N-1;
|
||||
// target 0 keeps only the session preamble.
|
||||
let keep_count = conversation_truncate_for_prompt(&conversation, target_index);
|
||||
conversation.truncate(keep_count);
|
||||
}
|
||||
|
||||
// Write the truncated conversation back via the actor
|
||||
// (handles both state update + persistence).
|
||||
self.chat_state_handle.replace_conversation(conversation);
|
||||
// Use a snapshot to set the correct prompt_index and truncated prompt_texts.
|
||||
// The actor's TruncateToPromptIndex doesn't apply here because the
|
||||
// conversation was already truncated locally. Instead, snapshot + restore
|
||||
// with the corrected fields.
|
||||
if let Some(mut snap) = self.chat_state_handle.snapshot().await {
|
||||
snap.prompt_index = target_index;
|
||||
snap.prompt_texts.truncate(target_index);
|
||||
// Cross-compaction rewind recomputes the marker (the rebuilt
|
||||
// conversation may have dropped the summary); standard
|
||||
// truncation keeps the existing marker.
|
||||
let new_marker =
|
||||
replay_compaction_marker.unwrap_or(snap.last_compaction_prompt_index);
|
||||
snap.last_compaction_prompt_index = new_marker;
|
||||
self.chat_state_handle.restore_snapshot(snap);
|
||||
}
|
||||
|
||||
// Conversation shrank — clear budget-based (size/schema) and stale
|
||||
// per-turn suppression so compaction can run against the smaller context.
|
||||
// Account-state suppression (credit/auth → SUPPRESS_UNTIL_SUCCESS) isn't
|
||||
// budget-related, so it persists until a successful model call.
|
||||
if self
|
||||
.compaction
|
||||
.auto_compact_suppressed
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
!= crate::session::compaction_config::SUPPRESS_UNTIL_SUCCESS
|
||||
{
|
||||
self.compaction.auto_compact_suppressed.store(
|
||||
crate::session::compaction_config::SUPPRESS_NONE,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
// Append a RewindMarker to updates.jsonl so the replay pipeline can
|
||||
// handle timeline branching (updates.jsonl is append-only).
|
||||
self.persist_xai_update_only(XaiSessionUpdate::RewindMarker {
|
||||
target_prompt_index: target_index,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
}
|
||||
|
||||
// Update the file state tracker to reflect the rewind.
|
||||
if wants_file_revert {
|
||||
// All/FilesOnly: files were reverted, snapshots are stale — truncate.
|
||||
self.file_state_tracker.truncate_from(target_index).await;
|
||||
let _ = self
|
||||
.notifications
|
||||
.persistence_tx
|
||||
.send(PersistenceMsg::TruncateRewindPoints {
|
||||
from_index: target_index,
|
||||
});
|
||||
} else if wants_conversation_rewind {
|
||||
// ConversationOnly: files are untouched but the conversation is rewound.
|
||||
self.merge_rewind_tracker_from(target_index).await;
|
||||
}
|
||||
|
||||
Ok(RewindResponse {
|
||||
success: true,
|
||||
target_prompt_index: target_index,
|
||||
mode,
|
||||
reverted_files,
|
||||
clean_files: vec![],
|
||||
conflicts,
|
||||
prompt_text,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ConversationOnly` rewind-tracker bookkeeping: merge the discarded
|
||||
/// prompts' file effects (`>= target_index`) into the previous rewind point
|
||||
/// so that (a) `/rewind 0` can still undo all file changes, and (b) a new
|
||||
/// prompt at `target_index` gets a fresh rewind point whose before-snapshots
|
||||
/// reflect current disk state. Files and the conversation are left untouched.
|
||||
///
|
||||
/// Updates the in-memory tracker, then persists via a disk-authoritative
|
||||
/// merge so a lazily-unloaded or partial tracker can't truncate history off
|
||||
/// disk. No normalize_to_relative needed: per-turn persistence already
|
||||
/// normalized the on-disk points (turn.rs, before PersistenceMsg::RewindPoint).
|
||||
///
|
||||
/// Shared by local `handle_rewind` (ConversationOnly) and the bridge-mode
|
||||
/// ConversationOnly path, whose conversation rewind lands server-side
|
||||
/// (SessionCommand::ReconcileRewindTracker).
|
||||
pub(super) async fn merge_rewind_tracker_from(&self, target_index: usize) {
|
||||
self.file_state_tracker
|
||||
.merge_and_remove_from(target_index)
|
||||
.await;
|
||||
let _ = self
|
||||
.notifications
|
||||
.persistence_tx
|
||||
.send(PersistenceMsg::MergeRewindPointsFrom { target_index });
|
||||
}
|
||||
|
||||
/// Out-of-band history repair (`x.ai/session/repair`) for a resident
|
||||
/// session: run `kigi_chat_state::compaction_utils::repair_history` inside
|
||||
/// the chat-state actor, then flush persistence so `chat_history.jsonl`
|
||||
/// is rewritten on disk before the caller sees success.
|
||||
///
|
||||
/// Refused while a turn is in flight (in-flight tool calls legitimately
|
||||
/// await their results). The refusal is enforced inside the chat-state
|
||||
/// actor's command handler — see `ChatStateCommand::RepairHistory` for
|
||||
/// why a caller-side check alone would race turn start; the check below
|
||||
/// is just a fast path.
|
||||
pub(super) async fn handle_repair_history(
|
||||
&self,
|
||||
dry_run: bool,
|
||||
) -> anyhow::Result<kigi_chat_state::compaction_utils::HistoryRepairReport> {
|
||||
// Per-session flag — NOT `tool_context.is_turn_active`, which is the
|
||||
// agent-wide coordinator flag shared by all sessions (using it would
|
||||
// refuse repair of an idle session while any other session runs a
|
||||
// turn, and another session's turn end could clear it mid-turn).
|
||||
let turn_flag = self.session_turn_active.clone();
|
||||
if turn_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
anyhow::bail!(kigi_chat_state::commands::RepairHistoryBlocked);
|
||||
}
|
||||
|
||||
let report = self
|
||||
.chat_state_handle
|
||||
.repair_history(dry_run, Some(turn_flag))
|
||||
.await
|
||||
.ok_or_else(|| anyhow::anyhow!("chat-state actor unavailable"))?
|
||||
.map_err(anyhow::Error::new)?;
|
||||
|
||||
if report.changed() && !dry_run {
|
||||
// Flush barrier: success must mean the rewrite is on disk.
|
||||
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
|
||||
if self
|
||||
.notifications
|
||||
.persistence_tx
|
||||
.send(PersistenceMsg::FlushAndAck {
|
||||
respond_to: flush_tx,
|
||||
})
|
||||
.is_err()
|
||||
|| flush_rx.await.is_err()
|
||||
{
|
||||
anyhow::bail!("history repaired in memory but the persistence flush failed");
|
||||
}
|
||||
tracing::warn!(
|
||||
session_id = %self.session_info.id.0,
|
||||
duplicates_removed = report.duplicates_removed,
|
||||
stripped_tool_result_ids = ?report.stripped_tool_result_ids,
|
||||
synthetic_results_inserted = report.synthetic_results_inserted,
|
||||
"session history repaired"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user