Add /graph G0: serial graph engineering mode over the goal engine

A deterministic DAG scheduler layered on the existing goal engine: /graph
<objective> decomposes the objective via a graph-planner subagent, gates the
result through Agentproof-style static validation (cycles, unknown deps,
duplicate slugs, caps), appends a structural final-verification node, then
executes each node as one ordinary goal — planner, worker loop, adversarial
verifier, budget and pause machinery all reused verbatim. The in-turn loop
advances nodes within the same turn (multi-loop closed loop); goal-side
auto-pauses cascade to the graph at a single chokepoint; node goals are armed
with the remaining graph budget so mid-node overruns trip graph-wide.

Gated by KIGI_GRAPH=1 (default off) + the goal harness. State persists to
<session_dir>/graph/state.json with a clear-tombstone; per-version immutable
baselines and per-node artifact archives live under graph/<graph_id>/.
Restore demotes Active->UserPaused and Running->Ready (verifier-gated re-run).

Review hardening (adversarial multi-agent pass, 24 confirmed findings fixed):
the goal-inactive loop break now consults the graph seam (mid-turn
classifier-disabled completions can no longer strand an Active graph), the
pause cascade fires even when no node goal is in flight (cancel during
planning), node bookkeeping precedes the long setup await, /graph clear only
resets the engine it owns, budget trips terminally fail the in-flight node,
and the pause transitions in /goal pause + /graph pause no longer hide inside
debug_assert! (a release-build no-op inherited from upstream).

Tests: 4908 kigi-shell lib tests green, including a serial 4-node closed-loop
e2e, restore/resume, cascade, mutual-exclusion, planning-retry, persistence
round-trip + tombstone, and status rendering.
This commit is contained in:
2026-07-20 14:07:45 -04:00
parent 2c29d3ecd5
commit 4361b03259
31 changed files with 3760 additions and 50 deletions
@@ -102,6 +102,9 @@ impl JsonlStorageAdapter {
fn goal_mode_state_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("goal").join("state.json")
}
fn graph_mode_state_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("graph").join("state.json")
}
fn rewind_points_file(&self, info: &Info) -> PathBuf {
self.session_dir(info).join("rewind_points.jsonl")
}
@@ -1095,6 +1098,29 @@ impl StorageAdapter for JsonlStorageAdapter {
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
}
async fn write_graph_mode_state(
&self,
info: &Info,
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
) -> io::Result<()> {
let target = self.graph_mode_state_file(info);
let Some(state) = state else {
// Tombstone: a cleared graph must not resurrect on restore.
return match tokio::fs::remove_file(&target).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
};
};
let json = serde_json::to_vec_pretty(state)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = target.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &target).await
}
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
let summary = self.read_summary_sync(info)?;
let chat_history =
@@ -1116,6 +1142,10 @@ impl StorageAdapter for JsonlStorageAdapter {
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
&self.goal_mode_state_file(info),
)?;
let graph_mode_state = self
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
&self.graph_mode_state_file(info),
)?;
let rewind_points = self.read_jsonl::<RewindPoint>(self.rewind_points_file(info))?;
let result = PersistedData {
summary,
@@ -1127,6 +1157,7 @@ impl StorageAdapter for JsonlStorageAdapter {
signals,
announcement_state,
goal_mode_state,
graph_mode_state,
};
tracing::info!(
session_id = % info.id, num_chat_messages = result.chat_history.len(),
@@ -1164,6 +1195,10 @@ impl StorageAdapter for JsonlStorageAdapter {
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
&self.goal_mode_state_file(info),
)?;
let graph_mode_state = self
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
&self.graph_mode_state_file(info),
)?;
let result = super::PersistedDataLight {
summary,
chat_history,
@@ -1172,6 +1207,7 @@ impl StorageAdapter for JsonlStorageAdapter {
signals,
announcement_state,
goal_mode_state,
graph_mode_state,
};
tracing::info!(
session_id = % info.id, num_chat_messages = result.chat_history.len(),
@@ -2751,3 +2751,45 @@ async fn load_session_without_updates_survives_merged_chat_line() {
"resume succeeds; only the merged record is dropped"
);
}
#[tokio::test]
async fn graph_mode_state_round_trips_and_tombstones() {
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
use crate::session::graph_tracker::{GraphNode, GraphOrchestration, NodeStatus};
let tmp = TempDir::new().unwrap();
let adapter = JsonlStorageAdapter::with_root(tmp.path().to_path_buf());
let info = create_test_info();
adapter.init_session(&info, default_model_id()).await.unwrap();
let state = GraphOrchestration {
graph_id: "g-1".into(),
objective: "obj".into(),
status: GoalStatus::Active,
phase: GoalPhase::Executing,
plan_version: 1,
nodes: vec![GraphNode {
id: "gn-1".into(), title: "T".into(), spec: "S".into(), deps: vec![],
status: NodeStatus::Achieved, goal_id: Some("goal-1".into()),
rounds: 2, tokens_used: 42, failure: None,
}],
current_node: None,
created_at: "2026-07-20T00:00:00Z".into(),
elapsed_ms: 5,
token_budget: Some(100),
tokens_spent_nodes: 42,
history: vec![],
pause_message: None,
};
adapter.write_graph_mode_state(&info, Some(&state)).await.unwrap();
let loaded = adapter.load_session_without_updates(&info).await.unwrap();
let got = loaded.graph_mode_state.expect("graph state must round-trip");
assert_eq!(got.graph_id, "g-1");
assert_eq!(got.nodes.len(), 1);
assert_eq!(got.nodes[0].status, NodeStatus::Achieved);
assert_eq!(got.nodes[0].tokens_used, 42);
assert_eq!(got.token_budget, Some(100));
// Tombstone removes the file; a second tombstone is not an error.
adapter.write_graph_mode_state(&info, None).await.unwrap();
adapter.write_graph_mode_state(&info, None).await.unwrap();
let after = adapter.load_session_without_updates(&info).await.unwrap();
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
}
@@ -271,6 +271,8 @@ pub struct PersistedData {
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
/// Persisted goal mode orchestration state (None for sessions without goal mode)
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
/// Persisted graph mode orchestration state (None for sessions without graph mode)
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
}
/// Persisted data WITHOUT updates - for memory-efficient session loading
@@ -288,6 +290,8 @@ pub struct PersistedDataLight {
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
/// Persisted goal mode orchestration state (None for sessions without goal mode)
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
/// Persisted graph mode orchestration state (None for sessions without graph mode)
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
}
/// Result of copying session data
@@ -595,6 +599,14 @@ pub trait StorageAdapter: Send + Sync {
state: &crate::session::goal_tracker::GoalOrchestration,
) -> io::Result<()>;
/// Write/update the graph mode orchestration state. `None` removes
/// the state file (tombstone after `/graph clear`).
async fn write_graph_mode_state(
&self,
info: &Info,
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
) -> io::Result<()>;
/// Load all persisted data for a session
async fn load_session(&self, info: &Info) -> io::Result<PersistedData>;