Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/graph_project.rs
T
ZacharyZhang-NY 27d009cb6e fix(fs): Windows-safe atomic replace everywhere — model switch now sticks
Root cause of 'model+effort switch works on Mac, not on Windows': the
switch APPLIES in-session (the dispatch/apply chain is platform-identical,
verified adversarially) but its persistence never sticks on Windows.
Every tmp+rename atomic write except auth/storage.rs committed with a
bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a
sharing violation whenever AV/search-indexer/cloud-sync transiently holds
the destination open. Consequences: [models].default never persisted
(next launch = original model), the session summary's current model never
persisted (resume = original model), and the models cache went silently
stale (all its write errors were swallowed).

- New kigi_shell_base::util::fs::replace_file — THE commit step for
  tmp+rename: plain rename on Unix; on Windows delete-first + two short
  backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on
  failure, error always returned. Windows branch type-checked against
  x86_64-pc-windows-msvc.
- Adopted at every replace site: config.toml (save_config /
  atomic_write_string / mcp saves), models cache (plus unique tmp
  suffixes and tracing::warn on failure — writes were fully silent),
  session storage (summary/current-model, jsonl, plan/signals/
  announcement/goal/graph state), auth.json, active-sessions registry,
  prompt history, claude/kimi import, campaigns state, goal artifacts.
  Directory-move renames (worktree pool, corrupt-file backups) keep
  plain rename — their destinations don't pre-exist.

Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean,
msvc-target check of the new cfg(windows) code clean.
2026-07-22 19:42:13 -04:00

306 lines
11 KiB
Rust

//! Project-level shared graph file (G4): `.kigi/graph.jsonl` at the git
//! root, so a graph follows the REPOSITORY, not the session.
//!
//! The session tracker remains the single source of truth; this file is
//! a PROJECTION refreshed at every checkpoint. Format (beads-style,
//! line-mergeable thanks to content-hash node ids):
//!
//! - line 1: the orchestration header (everything except `nodes`)
//! - lines 2..: one `GraphNode` per line
//!
//! Concurrency: an advisory `flock` on a sidecar `.lock` file makes the
//! session that CREATED or RESUMED the graph the single writer; other
//! kigi instances get a read-only view (`/graph status`) with an
//! explicit notice. The lock is held for the graph's lifetime in that
//! session and released on `/graph clear` (or process exit).
//!
//! Git discipline: kigi only WRITES the file — committing it is the
//! user's decision, never automated.
use std::io::Write;
use std::path::{Path, PathBuf};
use fs2::FileExt;
use super::graph_tracker::{GraphNode, GraphOrchestration};
/// Header line: the orchestration minus its nodes (which follow one per
/// line). The shadow `nodes` field is skipped on write and REJECTED on
/// read when non-empty — nodes embedded in the header would silently
/// duplicate the per-line entries.
#[derive(serde::Serialize, serde::Deserialize)]
struct ProjectGraphHeader {
#[serde(flatten)]
orchestration: GraphOrchestration,
}
fn header_has_inline_nodes(header: &ProjectGraphHeader) -> bool {
!header.orchestration.nodes.is_empty()
}
/// Held exclusive advisory lock on the project graph. Dropping releases.
#[derive(Debug)]
pub struct ProjectGraphLock {
_file: std::fs::File,
}
#[derive(Debug)]
pub enum LockOutcome {
Acquired(ProjectGraphLock),
/// Another kigi instance holds the lock.
Busy,
}
/// `.kigi` dir under the git root of `cwd`; `None` outside a git repo
/// (the project-graph feature is git-scoped by design).
pub fn project_graph_dir(cwd: &Path) -> Option<PathBuf> {
kigi_workspace::session::git::find_git_root_from_path(cwd)
.ok()
.map(|root| root.join(".kigi"))
}
pub fn graph_file_path(dir: &Path) -> PathBuf {
dir.join("graph.jsonl")
}
fn lock_file_path(dir: &Path) -> PathBuf {
dir.join("graph.jsonl.lock")
}
/// Try to become the project graph's single writer. Fail-fast: any I/O
/// error other than "already locked" propagates.
pub fn try_acquire_writer(dir: &Path) -> std::io::Result<LockOutcome> {
std::fs::create_dir_all(dir)?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(lock_file_path(dir))?;
match file.try_lock_exclusive() {
Ok(()) => Ok(LockOutcome::Acquired(ProjectGraphLock { _file: file })),
// fs2 maps contention differently per platform (EWOULDBLOCK on
// unix, ERROR_LOCK_VIOLATION on Windows); its
// `lock_contended_error()` is the portable classifier.
Err(err)
if err.kind() == std::io::ErrorKind::WouldBlock
|| err.raw_os_error() == fs2::lock_contended_error().raw_os_error() =>
{
Ok(LockOutcome::Busy)
}
Err(err) => Err(err),
}
}
/// Atomically project the orchestration to `.kigi/graph.jsonl`
/// (tmp + rename, same discipline as the session state file).
pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut header_state = state.clone();
let nodes = std::mem::take(&mut header_state.nodes);
let mut header_value = serde_json::to_value(&header_state)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(obj) = header_value.as_object_mut() {
// The contract says "minus nodes"; drop the empty vec the
// struct serializer would otherwise emit.
obj.remove("nodes");
}
let mut buf = Vec::with_capacity(4096);
serde_json::to_writer(&mut buf, &header_value)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
buf.push(b'\n');
for node in &nodes {
serde_json::to_writer(&mut buf, node)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
buf.push(b'\n');
}
let target = graph_file_path(dir);
let tmp = target.with_extension("jsonl.tmp");
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(&buf)?;
f.sync_all()?;
}
crate::util::fs::replace_file(&tmp, &target)
}
/// Load the projected graph, `Ok(None)` when absent. Malformed content
/// is an ERROR (never silently treated as "no graph") — the file is
/// user-visible, git-merged state; corruption must surface.
pub fn load(dir: &Path) -> std::io::Result<Option<GraphOrchestration>> {
let path = graph_file_path(dir);
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let mut lines = raw.lines().filter(|l| !l.trim().is_empty());
let Some(header_line) = lines.next() else {
return Ok(None);
};
let header: ProjectGraphHeader = serde_json::from_str(header_line).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{} line 1: {e}", path.display()),
)
})?;
if header_has_inline_nodes(&header) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{} line 1 embeds nodes inline; nodes belong one per line",
path.display()
),
));
}
let mut state = header.orchestration;
for (idx, line) in lines.enumerate() {
let node: GraphNode = serde_json::from_str(line).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{} node line {}: {e}", path.display(), idx + 2),
)
})?;
state.nodes.push(node);
}
Ok(Some(state))
}
/// Remove the projection (on `/graph clear`). Missing file is fine.
pub fn remove(dir: &Path) -> std::io::Result<()> {
match std::fs::remove_file(graph_file_path(dir)) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
use crate::session::graph_tracker::{DepKind, NodeDep, NodeStatus};
use tempfile::TempDir;
fn sample_state() -> GraphOrchestration {
GraphOrchestration {
graph_id: "g-1".into(),
objective: "ship it".into(),
status: GoalStatus::Active,
phase: GoalPhase::Executing,
plan_version: 2,
nodes: vec![
GraphNode {
id: "gn-aaaa".into(),
title: "A".into(),
spec: "do a".into(),
deps: vec![],
status: NodeStatus::Achieved,
goal_id: Some("goal-1".into()),
rounds: 2,
tokens_used: 100,
failure: None,
},
GraphNode {
id: "gn-bbbb".into(),
title: "B".into(),
spec: "do b".into(),
deps: vec![NodeDep {
on: "gn-aaaa".into(),
kind: DepKind::DiscoveredFrom,
}],
status: NodeStatus::Running,
goal_id: None,
rounds: 0,
tokens_used: 0,
failure: None,
},
],
current_node: Some("gn-bbbb".into()),
created_at: "2026-07-20T00:00:00Z".into(),
elapsed_ms: 12,
token_budget: Some(1_000),
tokens_spent_nodes: 100,
history: vec![],
pause_message: None,
pending_discoveries: vec![],
replan_runs: 1,
}
}
#[test]
fn project_load_round_trip_is_line_per_node() {
let tmp = TempDir::new().unwrap();
let state = sample_state();
project(tmp.path(), &state).unwrap();
let raw = std::fs::read_to_string(graph_file_path(tmp.path())).unwrap();
assert_eq!(raw.lines().count(), 3, "header + one line per node");
assert!(
!raw.lines().next().unwrap().contains("\"nodes\""),
"header must omit nodes entirely"
);
assert!(raw.lines().nth(1).unwrap().contains("gn-aaaa"));
let loaded = load(tmp.path()).unwrap().expect("present");
assert_eq!(loaded.graph_id, state.graph_id);
assert_eq!(loaded.plan_version, 2);
assert_eq!(loaded.nodes.len(), 2);
assert_eq!(loaded.nodes[1].deps[0].kind, DepKind::DiscoveredFrom);
assert_eq!(loaded.current_node.as_deref(), Some("gn-bbbb"));
}
#[test]
fn load_absent_is_none_and_remove_is_idempotent() {
let tmp = TempDir::new().unwrap();
assert!(load(tmp.path()).unwrap().is_none());
remove(tmp.path()).unwrap();
project(tmp.path(), &sample_state()).unwrap();
remove(tmp.path()).unwrap();
assert!(load(tmp.path()).unwrap().is_none());
remove(tmp.path()).unwrap();
}
#[test]
fn header_with_inline_nodes_is_rejected() {
let tmp = TempDir::new().unwrap();
let mut bad = serde_json::to_value(sample_state()).unwrap();
// Keep nodes inline in the header — a hand-edited/merged file.
bad.as_object_mut().unwrap().remove("current_node");
std::fs::write(graph_file_path(tmp.path()), format!("{bad}\n")).unwrap();
let err = load(tmp.path()).unwrap_err();
assert!(err.to_string().contains("inline"), "{err}");
}
#[test]
fn malformed_content_is_a_loud_error_not_a_missing_graph() {
let tmp = TempDir::new().unwrap();
std::fs::write(graph_file_path(tmp.path()), "not json\n").unwrap();
let err = load(tmp.path()).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("line 1"), "{err}");
}
#[test]
fn writer_lock_is_exclusive_within_and_across_handles() {
let tmp = TempDir::new().unwrap();
let first = try_acquire_writer(tmp.path()).unwrap();
let LockOutcome::Acquired(_guard) = first else {
panic!("first acquire must win");
};
match try_acquire_writer(tmp.path()).unwrap() {
LockOutcome::Busy => {}
LockOutcome::Acquired(_) => {
// flock is per-fd on some platforms within one process;
// if this arm is reached the platform lets the same
// process re-lock, which is still safe for our
// cross-INSTANCE contract — but on macOS/Linux flock
// between distinct fds does contend, so treat as bug.
panic!("second handle must observe Busy");
}
}
drop(_guard);
assert!(matches!(
try_acquire_writer(tmp.path()).unwrap(),
LockOutcome::Acquired(_)
));
}
}