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:
1 parent
bb5cbff62d
commit
771de1822d
15 files changed
+722
-7
No files matched your search
@@ -167,6 +167,21 @@ impl SessionActor {
|
||||
Some(state) => build_graph_updated(state),
|
||||
None => build_graph_cleared(),
|
||||
};
|
||||
// Project-level shared file (G4): refreshed at every checkpoint
|
||||
// while this session holds the writer lock. Failures are loud in
|
||||
// logs but never block progress — the session state below is the
|
||||
// durable source of truth.
|
||||
if let Some(dir) = &self.graph_project_dir
|
||||
&& self.graph_project_lock.borrow().is_some()
|
||||
{
|
||||
let result = match &snapshot {
|
||||
Some(state) => crate::session::graph_project::project(dir, state),
|
||||
None => crate::session::graph_project::remove(dir),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
tracing::warn!(%err, dir = %dir.display(), "graph: project file sync failed");
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
.notifications
|
||||
.persistence_tx
|
||||
@@ -174,6 +189,78 @@ impl SessionActor {
|
||||
self.goal_notify_sender().send_update(update);
|
||||
}
|
||||
|
||||
/// Best-effort peek at the projected graph's identity, for the
|
||||
/// lock-then-mutate sites: holding the flock proves nobody writes
|
||||
/// NOW, not that the file's content belongs to THIS session's graph.
|
||||
/// `None` = no file / unreadable (warned).
|
||||
pub(super) fn projected_graph_id(&self) -> Option<String> {
|
||||
let dir = self.graph_project_dir.as_deref()?;
|
||||
match crate::session::graph_project::load(dir) {
|
||||
Ok(state) => state.map(|s| s.graph_id),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "graph: projected file unreadable during identity check");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim project writership for a SESSION-owned graph about to
|
||||
/// resume: acquire the lock and verify the projected file (if any)
|
||||
/// belongs to this graph — a foreign projection must never be
|
||||
/// clobbered by our next checkpoint. `Some(msg)` = refuse with msg
|
||||
/// (lock released).
|
||||
pub(super) fn claim_project_graph_for_resume(&self) -> Option<String> {
|
||||
match self.acquire_project_graph_writer() {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Some(
|
||||
"Another kigi instance holds this project's graph (single-writer \
|
||||
lock); resume it there, or /graph clear here to drop this \
|
||||
session's copy."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return Some(format!("Failed to acquire the project graph lock: {err}"));
|
||||
}
|
||||
}
|
||||
let session_graph_id = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.snapshot()
|
||||
.map(|s| s.graph_id.clone());
|
||||
if let (Some(projected), Some(ours)) = (self.projected_graph_id(), session_graph_id)
|
||||
&& projected != ours
|
||||
{
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
return Some(format!(
|
||||
"The project graph file belongs to a different graph ({projected}); \
|
||||
this session's graph is {ours}. Use /graph resume in a fresh session \
|
||||
to revive the project graph, or /graph clear here first."
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Become the project graph's single writer. `Ok(false)` = another
|
||||
/// kigi instance owns it (caller degrades read-only); `Ok(true)` =
|
||||
/// acquired or no project dir (feature off outside git).
|
||||
pub(super) fn acquire_project_graph_writer(&self) -> std::io::Result<bool> {
|
||||
let Some(dir) = &self.graph_project_dir else {
|
||||
return Ok(true);
|
||||
};
|
||||
if self.graph_project_lock.borrow().is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
match crate::session::graph_project::try_acquire_writer(dir)? {
|
||||
crate::session::graph_project::LockOutcome::Acquired(lock) => {
|
||||
*self.graph_project_lock.borrow_mut() = Some(lock);
|
||||
Ok(true)
|
||||
}
|
||||
crate::session::graph_project::LockOutcome::Busy => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the graph occupies the goal engine (any non-terminal
|
||||
/// state). `/goal` commands are refused in this window.
|
||||
pub(super) fn graph_owns_goal_engine(&self) -> bool {
|
||||
@@ -211,6 +298,45 @@ impl SessionActor {
|
||||
}
|
||||
}
|
||||
|
||||
match self.acquire_project_graph_writer() {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return GraphSetupOutcome::Message(
|
||||
"Another kigi instance holds this project's graph (single-writer \
|
||||
lock). Use /graph status here for a read-only view, or run the \
|
||||
graph from that instance."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return GraphSetupOutcome::Message(format!(
|
||||
"Failed to acquire the project graph lock: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
// The project file may hold a dead session's REVIVABLE graph —
|
||||
// the same never-silently-replace rule as the session guard
|
||||
// above applies to it (a Complete one may be overwritten).
|
||||
if let Some(dir) = self.graph_project_dir.as_deref() {
|
||||
match crate::session::graph_project::load(dir) {
|
||||
Ok(Some(existing)) if existing.status != GoalStatus::Complete => {
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
return GraphSetupOutcome::Message(format!(
|
||||
"A project graph exists in .kigi/graph.jsonl ({}: {}). Use \
|
||||
/graph resume to revive it or /graph clear to discard it.",
|
||||
existing.graph_id, existing.objective,
|
||||
));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
return GraphSetupOutcome::Message(format!(
|
||||
"Project graph file is unreadable: {err}\n\
|
||||
Fix or delete .kigi/graph.jsonl, then retry."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let graph_id = uuid::Uuid::new_v4().to_string();
|
||||
tracing::info!(%graph_id, "graph: created, planning started");
|
||||
self.graph_tracker.lock().create_graph(
|
||||
@@ -757,9 +883,74 @@ impl SessionActor {
|
||||
use super::goal_support::GoalResumeOutcome;
|
||||
let status = self.graph_tracker.lock().status();
|
||||
match status {
|
||||
None => GraphSetupOutcome::Message(
|
||||
"No graph is set. Use /graph <objective> to start one.".to_owned(),
|
||||
),
|
||||
None => {
|
||||
// Cross-session revive: the graph follows the REPO. A
|
||||
// fresh session in the same project can pick it up from
|
||||
// .kigi/graph.jsonl (single-writer lock required).
|
||||
let Some(dir) = self.graph_project_dir.clone() else {
|
||||
return GraphSetupOutcome::Message(
|
||||
"No graph is set. Use /graph <objective> to start one.".to_owned(),
|
||||
);
|
||||
};
|
||||
// Lock BEFORE load: reading first races the owner's final
|
||||
// checkpoint/clear and could resurrect a just-cleared
|
||||
// graph from the pre-release copy.
|
||||
match self.acquire_project_graph_writer() {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return GraphSetupOutcome::Message(
|
||||
"Another kigi instance holds this project's graph \
|
||||
(single-writer lock); /graph status shows a read-only view."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return GraphSetupOutcome::Message(format!(
|
||||
"Failed to acquire the project graph lock: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let release_and = |msg: String| {
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
GraphSetupOutcome::Message(msg)
|
||||
};
|
||||
let loaded = match crate::session::graph_project::load(&dir) {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return release_and(
|
||||
"No graph is set. Use /graph <objective> to start one.".to_owned(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
return release_and(format!(
|
||||
"Project graph file is unreadable: {err}\n\
|
||||
Fix or delete .kigi/graph.jsonl, then retry."
|
||||
));
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
graph_id = %loaded.graph_id,
|
||||
"graph: revived from project file"
|
||||
);
|
||||
// from_snapshot sanitization (Active→UserPaused,
|
||||
// Running→Ready) applies to the revived state too.
|
||||
{
|
||||
let session_dir =
|
||||
crate::session::persistence::session_dir(&crate::session::info::Info {
|
||||
id: self.session_info.id.clone(),
|
||||
cwd: self.session_info.cwd.clone(),
|
||||
});
|
||||
*self.graph_tracker.lock() =
|
||||
crate::session::graph_tracker::GraphTracker::from_snapshot(
|
||||
session_dir,
|
||||
loaded,
|
||||
);
|
||||
}
|
||||
self.persist_graph_state();
|
||||
// Recurse exactly once: the tracker now holds a paused
|
||||
// graph, so the paused-family arm below handles it.
|
||||
Box::pin(self.resume_graph(extra_budget)).await
|
||||
}
|
||||
Some(GoalStatus::Active) => {
|
||||
GraphSetupOutcome::Message("Graph is already running.".to_owned())
|
||||
}
|
||||
@@ -774,6 +965,14 @@ impl SessionActor {
|
||||
.to_owned(),
|
||||
);
|
||||
};
|
||||
if extra <= 0 {
|
||||
return GraphSetupOutcome::Message(
|
||||
"Budget top-up must be a positive token count.".to_owned(),
|
||||
);
|
||||
}
|
||||
if let Some(msg) = self.claim_project_graph_for_resume() {
|
||||
return GraphSetupOutcome::Message(msg);
|
||||
}
|
||||
if !self.graph_tracker.lock().resume_budget_limited(extra) {
|
||||
return GraphSetupOutcome::Message(
|
||||
"Budget top-up must be a positive token count.".to_owned(),
|
||||
@@ -803,6 +1002,13 @@ impl SessionActor {
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
// A session-snapshot-restored graph resumes here WITHOUT
|
||||
// ever having passed the create/revive lock sites; claim
|
||||
// the project writer (with identity check) now or the
|
||||
// projection silently diverges forever.
|
||||
if let Some(msg) = self.claim_project_graph_for_resume() {
|
||||
return GraphSetupOutcome::Message(msg);
|
||||
}
|
||||
{
|
||||
let mut tracker = self.graph_tracker.lock();
|
||||
tracker.resume();
|
||||
@@ -924,8 +1130,32 @@ impl SessionActor {
|
||||
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
||||
let node_goal_tokens = self.goal_tokens_used(current_tokens);
|
||||
let tracker = self.graph_tracker.lock();
|
||||
let Some(s) = tracker.snapshot() else {
|
||||
return "No graph is set. Use /graph <objective> to start one.".to_owned();
|
||||
let project_fallback;
|
||||
let s = match tracker.snapshot() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
// Read-only project view (e.g. a second kigi instance
|
||||
// that does not hold the writer lock).
|
||||
match self
|
||||
.graph_project_dir
|
||||
.as_deref()
|
||||
.map(crate::session::graph_project::load)
|
||||
{
|
||||
Some(Ok(Some(state))) => {
|
||||
project_fallback = state;
|
||||
&project_fallback
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
return format!(
|
||||
"Project graph file is unreadable: {err}\n\
|
||||
Fix or delete .kigi/graph.jsonl."
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
return "No graph is set. Use /graph <objective> to start one.".to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let achieved = s
|
||||
.nodes
|
||||
|
||||
@@ -857,8 +857,57 @@ impl SessionActor {
|
||||
if self.graph_owns_goal_engine() {
|
||||
self.reset_goal_engine_state().await;
|
||||
}
|
||||
// Projection teardown only when there is something of
|
||||
// OURS to un-project: with no session graph, deleting
|
||||
// .kigi/graph.jsonl would destroy another session's
|
||||
// revivable graph while replying "No graph is set".
|
||||
let session_graph_id = self
|
||||
.graph_tracker
|
||||
.lock()
|
||||
.snapshot()
|
||||
.map(|s| s.graph_id.clone());
|
||||
self.graph_tracker.lock().clear();
|
||||
if had_graph {
|
||||
// Take the writer lock if we don't hold it (e.g. a
|
||||
// session-snapshot-restored graph cleared before any
|
||||
// resume). Busy = another instance owns the project
|
||||
// graph; local-only clear is then correct.
|
||||
match self.acquire_project_graph_writer() {
|
||||
Ok(true) => {
|
||||
// Identity check: only remove a projection
|
||||
// that belongs to the graph being cleared.
|
||||
let foreign = match (self.projected_graph_id(), &session_graph_id) {
|
||||
(Some(projected), Some(ours)) => projected != *ours,
|
||||
_ => false,
|
||||
};
|
||||
if foreign {
|
||||
tracing::info!(
|
||||
"graph clear: projection belongs to a different \
|
||||
graph; leaving .kigi/graph.jsonl in place"
|
||||
);
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::info!(
|
||||
"graph clear: another instance holds the project \
|
||||
graph; local session state cleared only"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
%err,
|
||||
"graph clear: project lock acquisition failed; \
|
||||
.kigi/graph.jsonl may survive as stale"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// persist runs BEFORE the lock drops so the projection
|
||||
// removal (when we hold writer rights on OUR graph)
|
||||
// executes; without the lock it is a session-only clear.
|
||||
self.persist_graph_state();
|
||||
self.graph_project_lock.borrow_mut().take();
|
||||
self.send_slash_command_output(if had_graph {
|
||||
"Graph cleared."
|
||||
} else {
|
||||
|
||||
@@ -431,6 +431,8 @@ pub(crate) async fn spawn_session_actor(
|
||||
};
|
||||
Arc::new(parking_lot::Mutex::new(tracker))
|
||||
};
|
||||
let graph_project_dir =
|
||||
crate::session::graph_project::project_graph_dir(tool_context.cwd.as_path());
|
||||
let graph_tracker = {
|
||||
let session_dir = crate::session::persistence::session_dir(&session_info);
|
||||
let tracker = if let Some(snapshot) = persisted_graph_mode {
|
||||
@@ -1102,6 +1104,8 @@ pub(crate) async fn spawn_session_actor(
|
||||
graph_concurrency: effective_config.resolve_graph_concurrency(),
|
||||
graph_node_rounds: effective_config.resolve_graph_node_rounds(),
|
||||
graph_replan_cap: effective_config.resolve_graph_replan_cap(),
|
||||
graph_project_dir,
|
||||
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),
|
||||
@@ -1248,8 +1252,13 @@ pub(crate) async fn spawn_session_actor(
|
||||
// A restored graph was demoted (Active→UserPaused, Running→Ready) IN
|
||||
// MEMORY after the updates-log replay, whose last GraphUpdated still
|
||||
// shows the pre-shutdown Active state. Re-emit truth once so a
|
||||
// reattached pager never renders a stale self-driving chip.
|
||||
// reattached pager never renders a stale self-driving chip — and
|
||||
// best-effort reclaim project writership so the shared file gets the
|
||||
// demoted truth too (Busy = another instance owns it; skip quietly).
|
||||
if session.graph_tracker.lock().snapshot().is_some() {
|
||||
if let Some(msg) = session.claim_project_graph_for_resume() {
|
||||
tracing::info!(%msg, "graph restore: project writership not reclaimed");
|
||||
}
|
||||
session.persist_graph_state();
|
||||
}
|
||||
if let Some(ref display_cwd) = prompt_display_cwd {
|
||||
|
||||
Reference in new issue
Block a user