Add /graph G4: project-level shared graph in .kigi/graph.jsonl

The graph now follows the REPOSITORY, not the session. Every checkpoint
projects the orchestration to .kigi/graph.jsonl at the git root in a
beads-style, line-mergeable shape: line 1 is the header (orchestration
minus nodes — omitted entirely, and a header carrying inline nodes is
rejected on load rather than silently duplicating the per-line entries),
then one content-hash-id node per line. The session tracker stays the
single source of truth; projection failures warn loudly but never block
progress. kigi only writes the file — committing it stays a user decision.

Single-writer discipline via an fs2 flock on a sidecar .lock: the session
that creates or resumes a graph owns the projection; other instances get a
read-only /graph status view rendered from the file and an explicit
refusal on resume. Cross-session revive: /graph resume in a fresh session
loads the file UNDER the lock (locking after reading raced the owner's
final checkpoint and could resurrect a just-cleared graph), sanitizes it
with the from_snapshot demotions, and re-dispatches.

Holding the lock proves nobody writes NOW — not that the file's content is
yours. Every lock-then-mutate site therefore identity-checks the projected
graph_id: /graph <objective> refuses to overwrite a foreign non-Complete
projection (revive-or-clear guidance, mirroring the session-level guard);
session-restored graphs claim writership on resume (and best-effort at
spawn re-emit, so the shared file learns the demoted truth immediately)
but refuse when the projection belongs to a different graph; /graph clear
skips projection teardown entirely when no session graph exists, leaves
foreign projections in place, and warns instead of swallowing lock errors.
Resume arms validate their flags before taking the lock.

Tests: projection round-trip pinning the one-line-per-node shape,
inline-nodes rejection, malformed-content loud errors, exclusive-lock
semantics across handles, and an e2e driving create → checkpoint
projection → second-instance read-only refusal → owner death → fresh-
session revive (demoted node relaunches) → clear removing the projection.
kigi-shell 4941 lib tests green; workspace clippy clean.
This commit is contained in:
2026-07-20 19:41:54 -04:00
parent bb5cbff62d
commit 771de1822d
15 changed files with 722 additions and 7 deletions
@@ -219,6 +219,8 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -662,6 +664,8 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -914,6 +918,8 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() {
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1899,6 +1905,8 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1800,3 +1800,95 @@ async fn replan_cap_zero_drains_discoveries_to_history_and_converges() {
.await;
unsafe { std::env::remove_var(ENV_FLAG) };
}
// ── G4: project-level shared graph ─────────────────────────────────
#[tokio::test(flavor = "current_thread")]
#[serial]
async fn project_graph_revives_in_a_fresh_session_and_write_lock_is_exclusive() {
unsafe { std::env::set_var(ENV_FLAG, "0") };
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let dag = chain_graph_json();
let (mut actor_a, tmp, _prx) = make_graph_actor_detached().await;
let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![dag]);
actor_a.tool_context.subagent_event_tx = Some(coord_tx);
// The fixture repo is the project root: point the projection
// there explicitly (find_git_root works too, but this keeps
// the test hermetic against outer repos).
let project_dir = tmp.path().join(".kigi");
actor_a.graph_project_dir = Some(project_dir.clone());
let _ = actor_a.setup_graph("ship the widget", None).await;
// Node a achieved, node b running; projection follows.
drive_node_goal_to_complete(&actor_a).await;
let _ = actor_a.run_graph_round_end().await;
let projected = crate::session::graph_project::load(&project_dir)
.unwrap()
.expect("checkpoint must project to .kigi/graph.jsonl");
assert_eq!(projected.nodes.len(), 4);
assert_eq!(projected.nodes[0].status, NodeStatus::Achieved);
assert_eq!(projected.nodes[1].status, NodeStatus::Running);
// While A holds the writer lock, a second instance is
// read-only: status renders from the file, resume refuses.
let (coord_b, _cb) = spawn_graph_planner_coordinator(vec![]);
let (mut actor_b, _tmp_b, _prx_b) = make_graph_actor_detached().await;
actor_b.tool_context.subagent_event_tx = Some(coord_b);
actor_b.graph_project_dir = Some(project_dir.clone());
let status = actor_b.graph_status_message().await;
assert!(
status.contains("ship the widget"),
"read-only view: {status}"
);
match actor_b.resume_graph(None).await {
graph::GraphSetupOutcome::Message(msg) => {
assert!(msg.contains("single-writer"), "{msg}");
}
graph::GraphSetupOutcome::Inference { .. } => {
panic!("second instance must not steal the writer lock")
}
}
// A dies (lock released); a FRESH session revives the graph
// from the project file: b demotes to Ready and relaunches.
actor_a.graph_project_lock.borrow_mut().take();
drop(actor_a);
let (coord_c, _cc) = spawn_graph_planner_coordinator(vec![]);
let (mut actor_c, _tmp_c, _prx_c) = make_graph_actor_detached().await;
actor_c.tool_context.subagent_event_tx = Some(coord_c);
actor_c.graph_project_dir = Some(project_dir.clone());
match actor_c.resume_graph(None).await {
graph::GraphSetupOutcome::Inference { reminder, .. } => {
assert!(
reminder.contains("Graph node 2/4"),
"revive must relaunch node b: {reminder}"
);
}
graph::GraphSetupOutcome::Message(msg) => {
panic!("fresh session must revive from the project file: {msg}")
}
}
assert_eq!(
actor_c.graph_tracker.lock().status(),
Some(GoalStatus::Active)
);
// /graph clear removes the projection and releases the lock.
drive_node_goal_to_complete(&actor_c).await;
let actor_c = StdArc::new(actor_c);
let _ = actor_c
.execute_builtin_slash_command(BuiltinAction::GraphClear)
.await;
assert!(
crate::session::graph_project::load(&project_dir)
.unwrap()
.is_none(),
"clear must remove .kigi/graph.jsonl"
);
assert!(actor_c.graph_project_lock.borrow().is_none());
})
.await;
unsafe { std::env::remove_var(ENV_FLAG) };
}
@@ -248,6 +248,8 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -180,6 +180,8 @@ async fn create_test_actor(
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -625,6 +627,8 @@ async fn create_test_actor_with_memory(
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -1381,6 +1385,8 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -242,6 +242,8 @@ async fn create_test_actor_with_memory(
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -188,6 +188,8 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),
@@ -293,6 +293,8 @@ pub(crate) async fn create_test_actor_ex(
graph_concurrency: 1,
graph_node_rounds: 3,
graph_replan_cap: 3,
graph_project_dir: None,
graph_project_lock: std::cell::RefCell::new(None),
goal_turn_task_ids: parking_lot::Mutex::new(std::collections::HashSet::new()),
goal_continuation_streak: std::sync::atomic::AtomicU32::new(0),
goal_blocked_streak: std::sync::atomic::AtomicU32::new(0),