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
@@ -1904,6 +1904,19 @@ impl SessionActor {
tokens_used,
finished_marginal,
);
// Graph cascade: node goals are armed with the REMAINING graph
// budget, so a node-level budget trip is the graph-level trip.
if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() {
tracing::warn!("graph: node goal budget trip cascades to graph BudgetLimited");
self.graph_tracker.lock().budget_limit();
self.persist_graph_state();
self.send_slash_command_output(&format!(
"Graph token budget reached ({tokens_used} tokens this node) — graph \
stopped. Use /graph clear, then /graph <objective> to start a new one."
))
.await;
return true;
}
self.send_slash_command_output(&format!(
"Goal token budget reached ({tokens_used} of {budget} tokens) — goal \
stopped. Use /goal clear, then /goal <objective> to start a new one."
@@ -2265,30 +2278,57 @@ impl SessionActor {
message: Option<String>,
) -> bool {
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
{
let graph_message = message.clone();
let goal_paused = {
let mut tracker = self.goal_tracker.lock();
if tracker.status() != Some(crate::session::goal_tracker::GoalStatus::Active) {
return false;
if tracker.status() == Some(crate::session::goal_tracker::GoalStatus::Active) {
// Active is guaranteed here, so the transition succeeds.
match message {
Some(msg) => tracker.pause_with_message(reason, msg),
None => tracker.pause(reason),
};
true
} else {
false
}
// The early-return above guarantees `Active`, so the pause
// transition always succeeds here.
match message {
Some(msg) => tracker.pause_with_message(reason, msg),
None => tracker.pause(reason),
};
};
if goal_paused {
self.clear_pending_classifier_completions();
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
let notify = self.goal_notify_sender();
notify.emit_goal_updated(
&mut self.goal_tracker.lock(),
tokens_used,
finished_marginal,
);
self.emit_event(crate::session::events::Event::GoalAutoPaused {
reason: reason.into(),
});
}
self.clear_pending_classifier_completions();
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
let notify = self.goal_notify_sender();
notify.emit_goal_updated(
&mut self.goal_tracker.lock(),
tokens_used,
finished_marginal,
);
self.emit_event(crate::session::events::Event::GoalAutoPaused {
reason: reason.into(),
});
true
// Graph cascade chokepoint: every goal auto-pause path funnels
// through here. It runs REGARDLESS of whether a goal pause
// applied — a cancel can land while the graph is Active with no
// node goal in the engine (during graph planning, or on the node
// boundary between engine reset and goal creation), and the
// graph must still lose its self-driving status. `pause` is
// Active-only, so double cascades are idempotent.
if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() {
let node = self
.graph_tracker
.lock()
.current_node_id()
.map(str::to_owned);
tracing::info!(reason = ?reason, node = ?node, goal_paused, "graph: cascading pause to graph");
let detail = match (&node, &graph_message) {
(Some(n), Some(msg)) => format!("Node {n} paused: {msg}"),
(Some(n), None) => format!("Node {n} paused ({reason:?})"),
(None, Some(msg)) => format!("Paused with no node goal in flight: {msg}"),
(None, None) => format!("Paused with no node goal in flight ({reason:?})"),
};
self.graph_tracker.lock().pause_with_message(reason, detail);
self.persist_graph_state();
}
goal_paused
}
/// Match the last assistant message text (via