diff --git a/AGENTS.md b/AGENTS.md index e72503a..b675467 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,37 @@ Cross-crate test hooks are behind the `test-support` cargo feature dependents' `[dev-dependencies]`. Don't expose new test seams as plain `#[cfg(test)]` items across crate boundaries. +## Graph mode (`/graph`, post-0.1.x — plan.md in the parent dir) + +A deterministic DAG scheduler layered over the goal engine: `/graph +` decomposes the objective into nodes (graph planner subagent +→ Agentproof-style static gate in `graph_plan.rs`), then executes each +node as one ordinary goal — the agentic loop lives INSIDE the node; the +edges stay deterministic Rust. The harness appends a terminal +`gn-final` verification node depending on every planner node. + +- Feature flag `KIGI_GRAPH=1` (default off); availability additionally + requires the goal harness (`BuiltinGate::Graph`). +- Key modules (kigi-shell): `session/graph_tracker.rs` (pure state + machine; reuses `GoalStatus`/`GoalPhase`/`GoalPauseReason`), + `session/graph_plan.rs` (planner-JSON contract + validation + fnv id + canonicalization), `session/graph_planner.rs` (planner runner, reuses + the goal planner spawn plumbing), + `session/acp_session_impl/graph.rs` (orchestration seam). +- Seam points: `handle_prompt` intercepts GraphSet/GraphResume; the + in-turn loop's `EndTurn` arm calls `run_graph_round_end()` to advance + nodes within the same turn; goal auto-pauses cascade to the graph in + `auto_pause_goal_if_active_inner`; node goals are armed with the + REMAINING graph budget so `enforce_goal_token_budget` cascades trips. +- Persistence: `PersistenceMsg::GraphModeState(Option<..>)` → + `/graph/state.json` (`None` tombstones after clear); + immutable per-version baselines `graph/graph.baseline.v{N}.json`; + per-node goal artifacts archived to `graph//`. Restore + demotes `Active`→`UserPaused` and `Running`→`Ready` (re-run is safe: + the verifier gates completion). +- `/goal` and `/graph` are mutually exclusive while the graph owns the + engine; e2e suite: `acp_session_tests/graph/graph_e2e_tests.rs`. + ## Milestones (PRD §8.3) - M0 (done): rename, deletions (voice/telemetry/announcements/marketplace/ diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 41963ce..19190c2 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -1912,6 +1912,13 @@ impl Config { .default(true) .resolve() } + /// Graph mode (`/graph`) master switch. Default OFF — gray-released via + /// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally + /// requires the goal harness (nodes execute as goals), enforced at + /// availability time, not here. + pub(crate) fn resolve_graph(&self) -> Resolved { + BoolFlag::env("KIGI_GRAPH").default(false).resolve() + } /// Classifier, planner, and summary all default to goal mode itself: when /// `/goal` is on they are on unless config/env/remote says otherwise. /// `goal_enabled` is the session's already-resolved master switch (the same diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs index 913402b..96e1333 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/acp_agent.rs @@ -754,6 +754,7 @@ impl acp::Agent for MvpAgent { persisted_signals: None, persisted_plan_mode: None, persisted_goal_mode: None, + persisted_graph_mode: None, persisted_announcement_state: None, session_meta: arguments.meta.as_ref(), model_agent_type: model_agent_type.as_deref(), @@ -985,6 +986,7 @@ impl acp::Agent for MvpAgent { signals: persisted_signals, announcement_state: persisted_announcement_state, goal_mode_state: _persisted_goal_mode, + graph_mode_state: _persisted_graph_mode, } = persistence_info; let restored_awaiting_plan_approval = persisted_plan_mode .as_ref() @@ -1207,6 +1209,7 @@ impl acp::Agent for MvpAgent { persisted_signals, persisted_plan_mode, persisted_goal_mode: _persisted_goal_mode, + persisted_graph_mode: _persisted_graph_mode, persisted_announcement_state, session_meta: request_meta.as_ref(), model_agent_type: persisted_agent_name.as_deref(), diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs index c741938..ed29120 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs @@ -1677,6 +1677,7 @@ impl MvpAgent { persisted_signals, persisted_plan_mode, persisted_goal_mode, + persisted_graph_mode, persisted_announcement_state, session_meta, model_agent_type, @@ -2111,6 +2112,7 @@ impl MvpAgent { let web_fetch_config = self.prepare_web_fetch_config(); let write_file_enabled = self.cfg.borrow().resolve_write_file().value; let goal_enabled = self.cfg.borrow().resolve_goal().value; + let graph_enabled = self.cfg.borrow().resolve_graph().value; let subagents_enabled = self.cfg.borrow().subagents_enabled; let ask_user_question_enabled = parse_ask_user_question_from_meta(session_meta) .unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value); @@ -2314,6 +2316,7 @@ impl MvpAgent { persisted_signals, persisted_plan_mode, persisted_goal_mode, + persisted_graph_mode, persisted_announcement_state, self.memory_config.clone(), feedback_flags, @@ -2328,6 +2331,7 @@ impl MvpAgent { app_builder_deployer_config, write_file_enabled, goal_enabled, + graph_enabled, subagents_enabled, ask_user_question_enabled, client_hooks, diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/mod.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/mod.rs index 41e20b8..86e512c 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/mod.rs @@ -120,6 +120,7 @@ pub(crate) struct SessionSpawnOptions<'a> { pub persisted_signals: Option, pub persisted_plan_mode: Option, pub persisted_goal_mode: Option, + pub persisted_graph_mode: Option, pub persisted_announcement_state: Option< crate::session::announcement_state::AnnouncementState, >, @@ -257,6 +258,7 @@ pub(crate) fn chat_session_spawn_options<'a>( persisted_signals: None, persisted_plan_mode: None, persisted_goal_mode: None, + persisted_graph_mode: None, persisted_announcement_state: None, session_meta, model_agent_type, diff --git a/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs b/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs index 3e907ed..ee31ca4 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs @@ -1036,6 +1036,7 @@ pub(crate) async fn handle_subagent_request( None, None, None, + None, if verbatim_mirror_fork { None } else if let Some(scope) = agent_memory_scope { @@ -1069,6 +1070,8 @@ pub(crate) async fn handle_subagent_request( ctx.app_builder_deployer_config.clone(), ctx.write_file_enabled, ctx.goal_enabled, + // Graph mode is a depth-0 harness; child sessions never drive it. + false, true, ctx.ask_user_question_enabled, ctx.client_hooks.clone(), diff --git a/crates/codegen/kigi-shell/src/session/acp_session.rs b/crates/codegen/kigi-shell/src/session/acp_session.rs index 785b6a4..6dd2f6c 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session.rs @@ -93,6 +93,8 @@ pub(crate) use types::*; pub use types::{TodoGateDecision, TodoGateReason}; #[path = "acp_session_impl/goal.rs"] mod goal; +#[path = "acp_session_impl/graph.rs"] +mod graph; #[path = "acp_session_impl/interjection.rs"] mod interjection; #[path = "acp_session_impl/tool_calls.rs"] @@ -589,6 +591,14 @@ pub(crate) struct SessionActor { /// Goal mode orchestration tracker. Session-scoped state for the /// Design-Execute-Verify loop. Modeled after `plan_mode` above. pub(crate) goal_tracker: Arc>, + /// Whether graph mode (`/graph`) is enabled for this session (feature + /// flag `KIGI_GRAPH`). Availability additionally requires the goal + /// harness — graph nodes execute as goals. + pub(crate) graph_enabled: bool, + /// Graph mode orchestration tracker: the deterministic DAG scheduler + /// layered over the goal engine. Modeled after `goal_tracker` above; + /// all graph state logic lives in `graph_tracker.rs`. + pub(crate) graph_tracker: Arc>, /// `task_id`s of background tasks (and monitors) that originated during /// the goal turn — either spawned by the goal model itself or reparented /// from a harness verifier/planner subagent on its exit. Their late @@ -985,6 +995,9 @@ impl SessionActor { hooks: self.hook_registry.borrow().is_some(), plugins: self.plugin_registry.borrow().is_some(), goal, + // Graph rides the goal harness: nodes execute as goals, so + // `/graph` is only real when `/goal` is. + graph: self.graph_enabled && goal, } } /// Names of every tool registered with the session's tool bridge. @@ -1423,6 +1436,9 @@ mod goal_strategist_e2e_tests; #[path = "acp_session_tests/goal/goal_summarizer_e2e_tests.rs"] mod goal_summarizer_e2e_tests; #[cfg(test)] +#[path = "acp_session_tests/graph/graph_e2e_tests.rs"] +mod graph_e2e_tests; +#[cfg(test)] #[path = "acp_session_tests/idle_resume_tests.rs"] mod idle_resume_tests; #[cfg(test)] diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs index 3758eed..b0a50f0 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/goal.rs @@ -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 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 to start a new one." @@ -2265,30 +2278,57 @@ impl SessionActor { message: Option, ) -> 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 diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/graph.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/graph.rs new file mode 100644 index 0000000..b61151a --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/graph.rs @@ -0,0 +1,723 @@ +//! Graph mode orchestration seam: a deterministic DAG scheduler layered +//! over the goal engine. +//! +//! Every node executes as one ordinary goal (planner → worker loop → +//! adversarial verifier), so the agentic loop lives inside the node and +//! this module only does the deterministic parts: decompose (graph +//! planner + static validation), launch the next `Ready` node as a fresh +//! goal, observe the goal's terminal state at the in-turn loop's +//! `EndTurn` boundary, advance the DAG, and persist every transition via +//! [`PersistenceMsg::GraphModeState`]. +//! +//! Failure semantics: goal-side auto-pauses cascade to the graph at the +//! single chokepoint (`auto_pause_goal_if_active_inner` in `goal.rs`), +//! and the node budget is always armed with the REMAINING graph budget, +//! so a mid-node overrun trips the goal engine's own enforcement and is +//! mirrored here as a graph-level `BudgetLimited`. + +use std::sync::Arc; + +use super::super::goal_planner::{ChannelSpawner, GoalPlannerSpawner}; +use super::super::goal_tracker::{GoalPauseReason, GoalStatus}; +use super::super::graph_planner::{ + GRAPH_PLANNER_SUBAGENT_DESCRIPTION, GraphPlannerInputs, GraphPlannerOutcome, run_graph_planner, +}; +use super::super::graph_tracker::{GraphNode, NodeStatus}; +use super::super::persistence::PersistenceMsg; +use super::SessionActor; + +/// Outcome of `/graph ` and `/graph resume` interception in +/// `handle_prompt`: either flow into inference with a system reminder +/// (mirrors [`GoalResumeOutcome`](super::goal_support::GoalResumeOutcome)) +/// or print a terminal message and end the turn. +pub(super) enum GraphSetupOutcome { + Inference { reminder: String, user_msg: String }, + Message(String), +} + +/// Compose the goal objective for one graph node. The node spec is the +/// contract; the graph context line keeps the node model from wandering +/// into other nodes' scope. +fn node_goal_objective( + graph_objective: &str, + node: &GraphNode, + position: usize, + total: usize, +) -> String { + format!( + "[Graph node {position}/{total}: {title}]\n\ + {spec}\n\n\ + This node is one unit of a larger graph objective:\n\ + {graph_objective}\n\n\ + Complete ONLY this node's scope; other nodes cover the rest.", + title = node.title, + spec = node.spec, + ) +} + +impl SessionActor { + /// Graph feature flag AND the goal harness (nodes execute as goals). + pub(super) fn graph_harness_enabled(&self) -> bool { + self.graph_enabled && self.goal_harness_enabled() + } + + /// Send the current graph snapshot (or a tombstone after `clear`) to + /// the persistence actor. Every graph transition is a checkpoint. + pub(crate) fn persist_graph_state(&self) { + let snapshot = self.graph_tracker.lock().snapshot().cloned(); + let _ = self + .notifications + .persistence_tx + .send(PersistenceMsg::GraphModeState(snapshot)); + } + + /// 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 { + self.graph_tracker + .lock() + .status() + .is_some_and(|s| s == GoalStatus::Active || s.is_paused()) + } + + /// Apply `/graph ` — create the graph, run the graph + /// planner (one validation retry with feedback), install the DAG, + /// freeze the immutable baseline, and launch the first node. + pub(super) async fn setup_graph( + &self, + objective: &str, + token_budget: Option, + ) -> GraphSetupOutcome { + { + let goal_status = self.goal_tracker.lock().status(); + if matches!(goal_status, Some(s) if s == GoalStatus::Active || s.is_paused()) { + return GraphSetupOutcome::Message( + "A goal is active or paused. Use /goal clear first, then /graph ." + .to_owned(), + ); + } + let graph_status = self.graph_tracker.lock().status(); + if matches!(graph_status, Some(s) if s == GoalStatus::Active || s.is_paused()) { + return GraphSetupOutcome::Message( + "A graph is already set. Use /graph status, /graph resume, or /graph clear." + .to_owned(), + ); + } + } + + let graph_id = uuid::Uuid::new_v4().to_string(); + tracing::info!(%graph_id, "graph: created, planning started"); + self.graph_tracker.lock().create_graph( + graph_id, + objective.to_owned(), + token_budget, + chrono::Utc::now().to_rfc3339(), + ); + self.persist_graph_state(); + + let nodes = match self.run_graph_planning(objective).await { + Ok(nodes) => nodes, + Err(reason) => { + tracing::warn!(%reason, "graph: planning failed; pausing"); + { + let mut tracker = self.graph_tracker.lock(); + tracker.record_planning_failed(reason.clone()); + tracker.pause_with_message( + GoalPauseReason::Infra, + format!("Graph planning failed: {reason}"), + ); + } + self.persist_graph_state(); + return GraphSetupOutcome::Message(format!( + "Graph planning failed: {reason}\n\ + Use /graph resume to retry or /graph clear to abandon." + )); + } + }; + + // Freeze the immutable v1 baseline BEFORE anything executes + // (SGH: plans are immutable within a version boundary). Failing + // to write the audit baseline is an infra failure, not ignorable. + if let Err(err) = self.write_graph_baseline(&nodes).await { + let reason = format!("failed to write graph baseline: {err}"); + tracing::warn!(%reason, "graph: baseline write failed; pausing"); + { + let mut tracker = self.graph_tracker.lock(); + tracker.record_planning_failed(reason.clone()); + tracker.pause_with_message(GoalPauseReason::Infra, reason.clone()); + } + self.persist_graph_state(); + return GraphSetupOutcome::Message(format!( + "{reason}\nUse /graph resume to retry or /graph clear to abandon." + )); + } + + let total = nodes.len(); + self.graph_tracker.lock().install_nodes(nodes); + self.persist_graph_state(); + tracing::info!(total, "graph: DAG installed, launching first node"); + + match self.launch_next_graph_node().await { + Some(reminder) => GraphSetupOutcome::Inference { + reminder, + user_msg: format!( + "Graph created: {total} nodes (incl. final verification). Starting node 1." + ), + }, + // Validation guarantees at least one root, so no Ready node + // here means the budget gate tripped inside the launcher. + None => GraphSetupOutcome::Message( + "Graph created but no node could start (budget exhausted?). See /graph status." + .to_owned(), + ), + } + } + + /// One planning pass with a single validation retry: an `Invalid` + /// artifact re-runs the planner once with the exact validation error + /// as CONTEXT; infra failures fail closed immediately. + async fn run_graph_planning(&self, objective: &str) -> Result, String> { + let Some(event_tx) = self.tool_context.subagent_event_tx.clone() else { + return Err("no subagent coordinator channel".to_owned()); + }; + let graph_file = self.graph_tracker.lock().artifacts_dir().join("graph.json"); + let parent_prompt_id = self + .current_prompt_id + .lock() + .expect("current_prompt_id mutex poisoned") + .clone(); + // Verbatim mirror-child fork on the parent model, same rationale + // as the goal planner (radix-cache reuse). + let spawner: Arc = Arc::new(ChannelSpawner { + event_tx, + parent_session_id: self.session_id_string(), + parent_prompt_id, + cwd: Some(self.tool_context.cwd.as_str().to_owned()), + role_override: Default::default(), + events: Some(self.events.writer()), + }); + let tool_names = self.resolve_inherit_role_tool_names().await; + + let mut feedback = String::new(); + for attempt in 1..=2u32 { + tracing::info!( + attempt, + role = GRAPH_PLANNER_SUBAGENT_DESCRIPTION, + "graph planner: firing" + ); + match run_graph_planner( + spawner.clone(), + GraphPlannerInputs { + objective, + feedback: &feedback, + graph_file: &graph_file, + tool_names: &tool_names, + inherit_tool_names: &tool_names, + }, + ) + .await + { + GraphPlannerOutcome::Planned(nodes) => return Ok(nodes), + GraphPlannerOutcome::Invalid { reason } if attempt == 1 => { + tracing::warn!(%reason, "graph planner: invalid artifact; retrying with feedback"); + feedback = format!( + "Your previous graph JSON failed validation:\n{reason}\n\ + Rewrite the file fixing exactly this." + ); + } + GraphPlannerOutcome::Invalid { reason } => { + return Err(format!("graph failed validation twice: {reason}")); + } + GraphPlannerOutcome::FailClosed { reason } => return Err(reason), + } + } + unreachable!("planning loop returns on every branch by attempt 2") + } + + /// Write the immutable plan baseline for the current version. + /// `create_new` guarantees a frozen baseline is never overwritten — + /// an existing file is the infra failure it looks like. + async fn write_graph_baseline(&self, nodes: &[GraphNode]) -> std::io::Result<()> { + let path = { + let tracker = self.graph_tracker.lock(); + let version = tracker.snapshot().map(|s| s.plan_version).unwrap_or(1); + tracker.baseline_path(version) + }; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let json = serde_json::to_vec_pretty(nodes) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .await?; + tokio::io::AsyncWriteExt::write_all(&mut file, &json).await + } + + /// Launch the next `Ready` node as a fresh goal on a clean engine. + /// Returns the node goal's system reminder to seed/continue the turn, + /// or `None` when nothing is launchable (all done / wedged / budget). + pub(super) async fn launch_next_graph_node(&self) -> Option { + let (node_id, node_objective, position, total) = { + let tracker = self.graph_tracker.lock(); + let node = tracker.next_ready_node()?; + let snapshot = tracker.snapshot()?; + let position = snapshot + .nodes + .iter() + .position(|n| n.id == node.id) + .unwrap_or(0) + + 1; + ( + node.id.clone(), + node_goal_objective(&snapshot.objective, node, position, snapshot.nodes.len()), + position, + snapshot.nodes.len(), + ) + }; + let node_budget = self.graph_tracker.lock().remaining_budget(); + if node_budget == Some(0) { + tracing::warn!(%node_id, "graph: budget exhausted before node start"); + self.graph_tracker.lock().budget_limit(); + self.persist_graph_state(); + self.send_slash_command_output( + "Graph token budget exhausted. Use /graph clear, then /graph .", + ) + .await; + return None; + } + + // Fresh engine per node: the previous node's goal state, token + // records, and pending classifier claims must not leak. + self.reset_goal_engine_state().await; + // Mark Running BEFORE the long setup_goal await (its planner run + // can take minutes): a cancel landing inside it then finds + // consistent bookkeeping — node Running, cascade pauses the + // graph, resume retries the node — instead of a Ready node whose + // goal is already live. + self.graph_tracker + .lock() + .mark_node_running(&node_id, String::new()); + self.persist_graph_state(); + let reminder = self.setup_goal(&node_objective, node_budget).await; + let goal_id = self + .goal_tracker + .lock() + .snapshot() + .map(|o| o.goal_id.clone()) + .unwrap_or_default(); + if let Some(node) = self + .graph_tracker + .lock() + .snapshot_mut() + .and_then(|s| s.nodes.iter_mut().find(|n| n.id == node_id)) + { + node.goal_id = Some(goal_id.clone()); + } + self.persist_graph_state(); + // setup_goal fails closed on planner errors by pausing the node + // goal — and the chokepoint cascade pauses the graph with it. Do + // NOT hand the now-stale "Start now" reminder to inference. + if self.goal_tracker.lock().status() != Some(GoalStatus::Active) { + tracing::warn!( + %node_id, + goal_status = ?self.goal_tracker.lock().status(), + "graph: node goal not active after setup; ending turn instead of launching" + ); + return None; + } + tracing::info!(%node_id, %goal_id, position, total, "graph: node launched"); + Some(reminder) + } + + /// Graph seam for the in-turn loop, called when the goal loop decided + /// `EndTurn`. Reads the node goal's terminal state, advances the DAG, + /// and returns `Some(reminder)` to keep the turn alive on the next + /// node — or `None` to genuinely end the turn (graph done, paused, + /// budget-limited, or not in graph mode at all). + pub(super) async fn run_graph_round_end(&self) -> Option { + if !self.graph_harness_enabled() || !self.graph_tracker.lock().is_active() { + return None; + } + let goal_status = self.goal_tracker.lock().status(); + match goal_status { + Some(GoalStatus::Complete) => self.advance_graph_after_node_complete().await, + Some(GoalStatus::BudgetLimited) => { + // Node goals are armed with the remaining graph budget, + // so a node-level trip IS the graph-level trip. The + // enforce-side cascade normally handles this; mirroring + // here is idempotent (budget_limit is Active-only). + tracing::warn!("graph: node goal budget-limited; graph budget-limited"); + self.graph_tracker.lock().budget_limit(); + self.persist_graph_state(); + None + } + Some(s) if s.is_paused() => { + // The auto-pause chokepoint cascade normally paused the + // graph before we got here (making is_active() false and + // returning early above). Reaching this arm means a pause + // path bypassed the chokepoint — mirror it, loudly. + tracing::warn!(status = ?s, "graph: node goal paused without cascade; mirroring"); + let reason = match s { + GoalStatus::BackOffPaused => GoalPauseReason::BackOff, + GoalStatus::NoProgressPaused => GoalPauseReason::NoProgress, + GoalStatus::InfraPaused => GoalPauseReason::Infra, + GoalStatus::Blocked => GoalPauseReason::Verification, + _ => GoalPauseReason::User, + }; + let message = self + .goal_tracker + .lock() + .snapshot() + .and_then(|o| o.pause_message.clone()); + let mut tracker = self.graph_tracker.lock(); + match message { + Some(msg) => tracker.pause_with_message(reason, msg), + None => tracker.pause(reason), + }; + drop(tracker); + self.persist_graph_state(); + None + } + other => { + // EndTurn with an Active goal cannot happen (an Active + // goal always yields Continue), and a missing goal while + // a node is Running is a launch bug. Pause loudly rather + // than leaving a self-driving graph with no engine. + tracing::error!( + goal_status = ?other, + current_node = ?self.graph_tracker.lock().current_node_id(), + "graph: inconsistent engine state at round end; pausing graph" + ); + self.graph_tracker.lock().pause_with_message( + GoalPauseReason::Infra, + format!("Inconsistent goal engine state at round end: {other:?}"), + ); + self.persist_graph_state(); + None + } + } + } + + /// The `Complete` arm of the seam: harvest the node's cost, archive + /// its goal artifacts, mark it achieved, and either finish the graph + /// or launch the next node. + async fn advance_graph_after_node_complete(&self) -> Option { + let Some(node_id) = self + .graph_tracker + .lock() + .current_node_id() + .map(str::to_owned) + else { + tracing::error!("graph: goal completed but no current node; pausing graph"); + self.graph_tracker.lock().pause_with_message( + GoalPauseReason::Infra, + "Goal completed with no current graph node".to_owned(), + ); + self.persist_graph_state(); + return None; + }; + + let current_tokens = self.chat_state_handle.get_total_tokens().await as i64; + let node_tokens = self.goal_tokens_used(current_tokens); + let rounds = self + .goal_tracker + .lock() + .snapshot() + .map(|o| o.total_worker_rounds) + .unwrap_or(0); + self.archive_node_artifacts(&node_id).await; + tracing::info!(%node_id, rounds, node_tokens, "graph: node achieved"); + self.graph_tracker + .lock() + .mark_node_achieved(&node_id, rounds, node_tokens); + self.persist_graph_state(); + + if self.graph_tracker.lock().all_achieved() { + let (total, objective) = { + let mut tracker = self.graph_tracker.lock(); + tracker.complete(); + let snapshot = tracker.snapshot(); + ( + snapshot.map(|s| s.nodes.len()).unwrap_or(0), + snapshot.map(|s| s.objective.clone()).unwrap_or_default(), + ) + }; + self.persist_graph_state(); + self.reset_goal_engine_state().await; + tracing::info!(total, "graph: complete"); + self.send_slash_command_output(&format!( + "Graph complete: all {total} nodes achieved (final verification included).\n\ + Objective: {objective}" + )) + .await; + return None; + } + + match self.launch_next_graph_node().await { + Some(reminder) => Some(reminder), + None => { + if self.graph_tracker.lock().is_wedged() { + tracing::warn!("graph: wedged (no runnable node, work remaining)"); + self.graph_tracker.lock().pause_with_message( + GoalPauseReason::Verification, + "No runnable node left: a dependency chain failed".to_owned(), + ); + self.persist_graph_state(); + self.send_slash_command_output( + "Graph blocked: a dependency chain failed and no runnable node is left. \ + See /graph status.", + ) + .await; + } + None + } + } + } + + /// Copy the node goal's durable artifacts (plan, plan baseline, + /// strategy note) into the node's archive dir before the engine is + /// cleared for the next node. Sources come from the ORCHESTRATION's + /// own claims (`plan_file`/`plan_baseline_file`/`last_strategy_path`), + /// never from bare path probing — a stale file left by a previous + /// node must not be archived as this node's work. Best-effort: a + /// failed copy loses audit detail, never progress — but always logs. + async fn archive_node_artifacts(&self, node_id: &str) { + let (sources, dst_dir) = { + let goal = self.goal_tracker.lock(); + let graph = self.graph_tracker.lock(); + let mut sources: Vec = Vec::new(); + if let Some(o) = goal.snapshot() { + if let Some(p) = &o.plan_file { + sources.push(p.clone()); + } + if let Some(p) = &o.plan_baseline_file { + sources.push(p.clone()); + } + if let Some(p) = &o.last_strategy_path { + sources.push(std::path::PathBuf::from(p)); + } + } + (sources, graph.node_archive_dir(node_id)) + }; + if sources.is_empty() { + return; + } + if let Err(err) = tokio::fs::create_dir_all(&dst_dir).await { + tracing::warn!(%node_id, %err, "graph: failed to create node archive dir"); + return; + } + for src in sources { + if !src.is_file() { + continue; + } + let Some(name) = src.file_name() else { + continue; + }; + if let Err(err) = tokio::fs::copy(&src, dst_dir.join(name)).await { + tracing::warn!(%node_id, src = %src.display(), %err, "graph: artifact archive copy failed"); + } + } + } + + /// Clear all goal-engine session state (tracker, streaks, task ids, + /// token records, pending classifier claims) and tell the pager. + /// Shared by `/goal clear`, graph node boundaries, and `/graph clear`. + pub(super) async fn reset_goal_engine_state(&self) { + self.goal_tracker.lock().clear(); + self.goal_continuation_streak + .store(0, std::sync::atomic::Ordering::Relaxed); + self.goal_blocked_streak + .store(0, std::sync::atomic::Ordering::Relaxed); + self.goal_turn_task_ids.lock().clear(); + self.subagent_token_records.lock().clear(); + self.clear_pending_classifier_completions(); + let update = crate::session::goal_orchestrator::build_goal_cleared(); + self.send_xai_notification(update).await; + } + + /// Apply `/graph resume`: re-arm a paused graph. If the current node's + /// goal is still in the engine and paused, resume it (planner retry + /// included); otherwise (post-restart empty engine) launch the next + /// `Ready` node fresh — the verifier gates completion, so re-running + /// a node is always safe. + pub(super) async fn resume_graph(&self) -> GraphSetupOutcome { + use super::goal_support::GoalResumeOutcome; + let status = self.graph_tracker.lock().status(); + match status { + None => GraphSetupOutcome::Message( + "No graph is set. Use /graph to start one.".to_owned(), + ), + Some(GoalStatus::Active) => { + GraphSetupOutcome::Message("Graph is already running.".to_owned()) + } + Some(GoalStatus::Complete) => GraphSetupOutcome::Message( + "Graph is already complete. Use /graph to start a new one.".to_owned(), + ), + Some(GoalStatus::BudgetLimited) => GraphSetupOutcome::Message( + "Graph is budget-limited. Use /graph clear, then /graph .".to_owned(), + ), + Some(s) if s.is_paused() => { + self.graph_tracker.lock().resume(); + self.persist_graph_state(); + tracing::info!("graph: resumed"); + // Planning never finished? Re-plan before touching nodes. + let needs_planning = self + .graph_tracker + .lock() + .snapshot() + .is_some_and(|s| s.nodes.is_empty()); + if needs_planning { + let objective = self + .graph_tracker + .lock() + .objective() + .map(str::to_owned) + .unwrap_or_default(); + return match self.finish_planning_on_resume(&objective).await { + Ok(Some(reminder)) => GraphSetupOutcome::Inference { + reminder, + user_msg: "Graph resumed; planning retried.".to_owned(), + }, + Ok(None) => GraphSetupOutcome::Message( + "Graph resumed but no node could start. See /graph status.".to_owned(), + ), + Err(msg) => GraphSetupOutcome::Message(msg), + }; + } + let goal_paused = self + .goal_tracker + .lock() + .status() + .is_some_and(|s| s.is_paused()); + if goal_paused { + match self.resume_goal().await { + GoalResumeOutcome::Inference { reminder, user_msg } => { + GraphSetupOutcome::Inference { + reminder, + user_msg: format!("Graph resumed. {user_msg}"), + } + } + GoalResumeOutcome::Message(msg) => { + // The node goal re-paused (e.g. planner failed + // again); the cascade re-paused the graph. + GraphSetupOutcome::Message(format!("Graph resume: {msg}")) + } + } + } else { + match self.launch_next_graph_node().await { + Some(reminder) => GraphSetupOutcome::Inference { + reminder, + user_msg: "Graph resumed.".to_owned(), + }, + None => GraphSetupOutcome::Message( + "Graph resumed but no node is runnable. See /graph status.".to_owned(), + ), + } + } + } + Some(other) => GraphSetupOutcome::Message(format!( + "Graph is in an unexpected state ({other:?}); use /graph status." + )), + } + } + + /// Resume path for a graph that paused during planning: retry the + /// planner, install on success, launch the first node. + async fn finish_planning_on_resume(&self, objective: &str) -> Result, String> { + match self.run_graph_planning(objective).await { + Ok(nodes) => { + if let Err(err) = self.write_graph_baseline(&nodes).await { + let reason = format!("failed to write graph baseline: {err}"); + self.graph_tracker + .lock() + .pause_with_message(GoalPauseReason::Infra, reason.clone()); + self.persist_graph_state(); + return Err(reason); + } + self.graph_tracker.lock().install_nodes(nodes); + self.persist_graph_state(); + Ok(self.launch_next_graph_node().await) + } + Err(reason) => { + { + let mut tracker = self.graph_tracker.lock(); + tracker.record_planning_failed(reason.clone()); + tracker.pause_with_message( + GoalPauseReason::Infra, + format!("Graph planning failed: {reason}"), + ); + } + self.persist_graph_state(); + Err(format!( + "Graph planning failed again: {reason}\nUse /graph resume to retry." + )) + } + } + } + + /// Render the `/graph status` tree. + pub(super) async fn graph_status_message(&self) -> String { + 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 to start one.".to_owned(); + }; + let achieved = s + .nodes + .iter() + .filter(|n| n.status == NodeStatus::Achieved) + .count(); + let mut buf = format!( + "Graph: {}\nStatus: {:?} | Phase: {:?} | Plan v{}\nNodes: {achieved}/{} achieved\n", + s.objective, + s.status, + s.phase, + s.plan_version, + s.nodes.len(), + ); + for node in &s.nodes { + let glyph = match node.status { + NodeStatus::Achieved => "[x]", + NodeStatus::Running | NodeStatus::Verifying => "[>]", + NodeStatus::Ready => "[ ]", + NodeStatus::Waiting => "[.]", + NodeStatus::Failed => "[!]", + NodeStatus::Blocked => "[-]", + }; + buf.push_str(&format!(" {glyph} {} — {}", node.id, node.title)); + if node.status == NodeStatus::Waiting && !node.deps.is_empty() { + let deps: Vec<&str> = node.deps.iter().map(|d| d.on.as_str()).collect(); + buf.push_str(&format!(" (waiting on {})", deps.join(", "))); + } + if node.status == NodeStatus::Achieved { + buf.push_str(&format!( + " ({} tokens, {} rounds)", + node.tokens_used, node.rounds + )); + } + if let Some(failure) = &node.failure { + buf.push_str(&format!(" — {failure}")); + } + buf.push('\n'); + } + let mut tokens = s.tokens_spent_nodes; + if s.current_node.is_some() { + tokens += node_goal_tokens; + } + buf.push_str(&format!("Tokens: {tokens}")); + if let Some(budget) = s.token_budget { + buf.push_str(&format!(" | Budget: {budget}")); + } + if let Some(node_id) = &s.current_node { + buf.push_str(&format!("\nCurrent node: {node_id}")); + } + if let Some(msg) = &s.pause_message { + buf.push_str(&format!("\nPaused: {msg}")); + } + buf + } +} diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs index 0366642..7f46966 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/slash_exec.rs @@ -747,16 +747,24 @@ impl SessionActor { ok_end_turn(0, None) } BuiltinAction::GoalPause => { + if self.graph_owns_goal_engine() { + self.send_slash_command_output( + "A graph owns the goal engine. Use /graph pause instead.", + ) + .await; + return ok_end_turn(0, None); + } let current_tokens = self.chat_state_handle.get_total_tokens().await as i64; use crate::session::goal_tracker::{GoalPauseReason, GoalStatus}; let (msg, changed) = { let mut tracker = self.goal_tracker.lock(); match tracker.status() { Some(GoalStatus::Active) => { - debug_assert!( - tracker.pause(GoalPauseReason::User), - "Active goal must pause" - ); + // Side effect OUTSIDE the assert: debug_assert! + // strips its condition in release builds, which + // would silently skip the pause itself. + let paused = tracker.pause(GoalPauseReason::User); + debug_assert!(paused, "Active goal must pause"); ("Goal paused. Use /goal resume to continue.", true) } Some( @@ -790,27 +798,84 @@ impl SessionActor { unreachable!("GoalResume is intercepted in handle_prompt") } BuiltinAction::GoalClear => { - self.goal_tracker.lock().clear(); - // `/goal clear` is a deliberate user reset — drop both - // streaks so stale counters from the previous goal - // can't leak into the next one. - self.goal_continuation_streak - .store(0, std::sync::atomic::Ordering::Relaxed); - self.goal_blocked_streak - .store(0, std::sync::atomic::Ordering::Relaxed); - // Drop goal-turn-origin task ids so a future goal's drain - // doesn't suppress the next goal's (or post-goal) tasks. - self.goal_turn_task_ids.lock().clear(); - // Clear per-subagent token records so stale entries - // from the previous goal don't leak into the next. - self.subagent_token_records.lock().clear(); - self.clear_pending_classifier_completions(); - // Emit a cleared notification so the pager drops goal state. - let update = crate::session::goal_orchestrator::build_goal_cleared(); - self.send_xai_notification(update).await; + if self.graph_owns_goal_engine() { + self.send_slash_command_output( + "A graph owns the goal engine. Use /graph clear instead.", + ) + .await; + return ok_end_turn(0, None); + } + // `/goal clear` is a deliberate user reset — the shared + // helper drops the tracker, both streaks, goal-turn task + // ids, per-subagent token records, pending classifier + // claims, and notifies the pager. Shared with the graph + // node boundary and `/graph clear`. + self.reset_goal_engine_state().await; self.send_slash_command_output("Goal cleared.").await; ok_end_turn(0, None) } + BuiltinAction::GraphStatus => { + let msg = self.graph_status_message().await; + self.send_slash_command_output(&msg).await; + ok_end_turn(0, None) + } + BuiltinAction::GraphPause => { + use crate::session::goal_tracker::{GoalPauseReason, GoalStatus}; + let (msg, changed) = { + let mut tracker = self.graph_tracker.lock(); + match tracker.status() { + Some(GoalStatus::Active) => { + // Side effect OUTSIDE the assert: debug_assert! + // strips its condition in release builds, which + // would silently skip the pause itself. + let paused = tracker.pause(GoalPauseReason::User); + debug_assert!(paused, "Active graph must pause"); + ("Graph paused. Use /graph resume to continue.", true) + } + Some(s) if s.is_paused() => ("Graph is already paused.", false), + Some(GoalStatus::Complete) => ("Graph is already complete.", false), + Some(GoalStatus::BudgetLimited) => ("Graph is budget-limited.", false), + Some(_) | None => ("No graph is currently set.", false), + } + }; + if changed { + // Pause the running node's goal too so the in-turn + // loop stops at the next round boundary. + self.auto_pause_goal_if_active(GoalPauseReason::User).await; + self.persist_graph_state(); + } + self.send_slash_command_output(msg).await; + ok_end_turn(0, None) + } + BuiltinAction::GraphClear => { + let had_graph = self.graph_tracker.lock().snapshot().is_some(); + // Clear the goal engine ONLY when the graph owns it + // (Active/paused ⇒ the engine's goal is a node goal). A + // terminal graph (Complete/BudgetLimited) may coexist + // with an unrelated standalone /goal the user started + // afterwards — that goal must survive /graph clear. + if self.graph_owns_goal_engine() { + self.reset_goal_engine_state().await; + } + self.graph_tracker.lock().clear(); + self.persist_graph_state(); + self.send_slash_command_output(if had_graph { + "Graph cleared." + } else { + "No graph is currently set." + }) + .await; + ok_end_turn(0, None) + } + // GraphSet / GraphResume are intercepted in handle_prompt + // (like GoalSet / GoalResume) so a successful setup/resume + // flows through to model inference. + BuiltinAction::GraphSet { .. } => { + unreachable!("GraphSet is intercepted in handle_prompt") + } + BuiltinAction::GraphResume => { + unreachable!("GraphResume is intercepted in handle_prompt") + } } } diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs index 3c59c66..360d3ed 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs @@ -136,6 +136,7 @@ pub(crate) async fn spawn_session_actor( persisted_signals: Option, persisted_plan_mode: Option, persisted_goal_mode: Option, + persisted_graph_mode: Option, persisted_announcement_state: Option, memory_config: Option, feedback_flags: crate::session::feedback_manager::FeedbackFlags, @@ -150,6 +151,7 @@ pub(crate) async fn spawn_session_actor( app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig, write_file_enabled: bool, goal_enabled: bool, + graph_enabled: bool, subagents_enabled: bool, ask_user_question_enabled: bool, client_hooks: crate::extensions::hooks::ClientHooks, @@ -429,6 +431,15 @@ pub(crate) async fn spawn_session_actor( }; Arc::new(parking_lot::Mutex::new(tracker)) }; + let graph_tracker = { + let session_dir = crate::session::persistence::session_dir(&session_info); + let tracker = if let Some(snapshot) = persisted_graph_mode { + crate::session::graph_tracker::GraphTracker::from_snapshot(session_dir, snapshot) + } else { + crate::session::graph_tracker::GraphTracker::new(session_dir) + }; + Arc::new(parking_lot::Mutex::new(tracker)) + }; let current_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent)); let turn_prompt_mode = Arc::new(parking_lot::Mutex::new(PromptMode::Agent)); let task_output_tool_name = Arc::new(std::sync::OnceLock::new()); @@ -1086,6 +1097,8 @@ pub(crate) async fn spawn_session_actor( goal_harness_enabled: std::sync::atomic::AtomicBool::new(false), goal_harness_availability_reconciled: std::sync::atomic::AtomicBool::new(false), goal_tracker, + graph_enabled, + graph_tracker, 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), @@ -1511,6 +1524,7 @@ pub(crate) async fn spawn_session_on_thread( persisted_signals: Option, persisted_plan_mode: Option, persisted_goal_mode: Option, + persisted_graph_mode: Option, persisted_announcement_state: Option, memory_config: Option, feedback_flags: crate::session::feedback_manager::FeedbackFlags, @@ -1525,6 +1539,7 @@ pub(crate) async fn spawn_session_on_thread( app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig, write_file_enabled: bool, goal_enabled: bool, + graph_enabled: bool, subagents_enabled: bool, ask_user_question_enabled: bool, client_hooks: crate::extensions::hooks::ClientHooks, @@ -1653,6 +1668,7 @@ pub(crate) async fn spawn_session_on_thread( persisted_signals, persisted_plan_mode, persisted_goal_mode, + persisted_graph_mode, persisted_announcement_state, memory_config, feedback_flags, @@ -1667,6 +1683,7 @@ pub(crate) async fn spawn_session_on_thread( app_builder_deployer_config, write_file_enabled, goal_enabled, + graph_enabled, subagents_enabled, ask_user_question_enabled, client_hooks, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs index 7414561..2720000 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/turn.rs @@ -305,15 +305,57 @@ impl SessionActor { objective, token_budget, } => { + // The graph owns the goal engine while set: a + // manual /goal would corrupt the running node. + if self.graph_owns_goal_engine() { + self.send_slash_command_output( + "A graph owns the goal engine. Use /graph status, or /graph \ + clear before /goal.", + ) + .await; + return ok_end_turn(0, None); + } let reminder = self.setup_goal(&objective, token_budget).await; vec![text_block(reminder), text_block(objective)] } - BuiltinAction::GoalResume => match self.resume_goal().await { - GoalResumeOutcome::Inference { reminder, user_msg } => { + BuiltinAction::GoalResume => { + if self.graph_owns_goal_engine() { + self.send_slash_command_output( + "A graph owns the goal engine. Use /graph resume instead.", + ) + .await; + return ok_end_turn(0, None); + } + match self.resume_goal().await { + GoalResumeOutcome::Inference { reminder, user_msg } => { + self.send_slash_command_output(&user_msg).await; + vec![text_block(reminder)] + } + GoalResumeOutcome::Message(msg) => { + self.send_slash_command_output(&msg).await; + return ok_end_turn(0, None); + } + } + } + BuiltinAction::GraphSet { + objective, + token_budget, + } => match self.setup_graph(&objective, token_budget).await { + super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => { + self.send_slash_command_output(&user_msg).await; + vec![text_block(reminder), text_block(objective)] + } + super::graph::GraphSetupOutcome::Message(msg) => { + self.send_slash_command_output(&msg).await; + return ok_end_turn(0, None); + } + }, + BuiltinAction::GraphResume => match self.resume_graph().await { + super::graph::GraphSetupOutcome::Inference { reminder, user_msg } => { self.send_slash_command_output(&user_msg).await; vec![text_block(reminder)] } - GoalResumeOutcome::Message(msg) => { + super::graph::GraphSetupOutcome::Message(msg) => { self.send_slash_command_output(&msg).await; return ok_end_turn(0, None); } @@ -688,13 +730,35 @@ impl SessionActor { self.goal_tracker.lock().status(), ); if !goal_active { - break round; + // The node goal may have resolved MID-round without a + // graph cascade (e.g. a classifier-disabled completion + // applied by the mid-turn drainer). Consult the graph + // seam before ending the turn so the graph advances or + // settles loudly instead of stranding Active forever. + match self.run_graph_round_end().await { + Some(node_reminder) => { + self.inject_goal_continuation_message(node_reminder).await; + continue; + } + None => break round, + } } match self.run_goal_round_end().await { GoalRoundDecision::Continue(directive) => { self.inject_goal_continuation_message(directive).await; } - GoalRoundDecision::EndTurn => break round, + GoalRoundDecision::EndTurn => { + // Graph seam: when the node goal resolved, the + // graph may advance to the next node inside the + // SAME turn (multi-loop closed loop). None ends + // the turn for real (graph done/paused/absent). + match self.run_graph_round_end().await { + Some(node_reminder) => { + self.inject_goal_continuation_message(node_reminder).await; + } + None => break round, + } + } } } }; diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs index 935c16a..f4f5d00 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/cancel_running_task_tests.rs @@ -212,6 +212,10 @@ async fn persist_ack_waits_for_disk_flush_before_success() { "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), @@ -648,6 +652,10 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history() "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), @@ -893,6 +901,10 @@ async fn cancel_running_task_teardown_clears_running_and_pending_work() { "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), @@ -1871,6 +1883,10 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() { "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs new file mode 100644 index 0000000..da6e521 --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/graph/graph_e2e_tests.rs @@ -0,0 +1,695 @@ +//! End-to-end coverage for the `/graph` orchestration seam: serial +//! multi-node execution over the goal engine, restore/resume, budget and +//! pause cascades, goal-command mutual exclusion, and planning-failure +//! handling. Same single-thread + LocalSet + coordinator-stub pattern as +//! the goal e2e suites; the classifier is disabled so a node completes +//! on the classifier-disabled fast path. + +use super::support::*; +use super::*; +use crate::session::goal_tracker::{GoalPauseReason, GoalStatus}; +use crate::session::graph_tracker::{GraphTracker, NodeStatus}; +use kigi_tools::implementations::kigi::task::types::{SubagentEvent, SubagentResult}; +use kigi_tools::implementations::kigi::update_goal::{UpdateGoalInput, envelope_for_test}; +use serial_test::serial; +use std::sync::Arc as StdArc; +use std::sync::atomic::{AtomicUsize, Ordering as SeqOrd}; +use tempfile::TempDir; + +const ENV_FLAG: &str = "KIGI_GOAL_CLASSIFIER"; + +/// A valid 3-node chain a → b → c (the harness appends `gn-final`). +fn chain_graph_json() -> Vec { + serde_json::json!({ + "nodes": [ + {"id": "a", "title": "Node A", "spec": "do a", "deps": []}, + {"id": "b", "title": "Node B", "spec": "do b", "deps": ["a"]}, + {"id": "c", "title": "Node C", "spec": "do c", "deps": ["b"]}, + ] + }) + .to_string() + .into_bytes() +} + +/// Self-dependency — fails static validation. +fn invalid_graph_json() -> Vec { + br#"{"nodes":[{"id":"a","title":"A","spec":"s","deps":["a"]}]}"#.to_vec() +} + +/// Coordinator stub for the GRAPH planner: on each `Spawn`, pops the +/// next body from the FIFO, writes it to the `graph.json` path parsed +/// out of the rendered prompt, and answers `Done`. Panics if spawned +/// more times than bodies were provided (a silent extra spawn would +/// hide a retry-loop bug). +fn spawn_graph_planner_coordinator( + bodies: Vec>, +) -> ( + tokio::sync::mpsc::UnboundedSender, + StdArc, +) { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let spawn_count = StdArc::new(AtomicUsize::new(0)); + let count_task = StdArc::clone(&spawn_count); + tokio::task::spawn_local(async move { + let mut bodies = std::collections::VecDeque::from(bodies); + while let Some(ev) = rx.recv().await { + if let SubagentEvent::Spawn(req) = ev { + let n = count_task.fetch_add(1, SeqOrd::SeqCst); + let body = bodies + .pop_front() + .unwrap_or_else(|| panic!("unexpected planner spawn #{}", n + 1)); + let graph_path = req.prompt.find("/graph.json").map(|end_idx| { + let end = end_idx + "/graph.json".len(); + let start = req.prompt[..end_idx] + .rfind(|c: char| !c.is_ascii_graphic() || c == '`') + .map(|i| i + 1) + .unwrap_or(0); + req.prompt[start..end].to_string() + }); + let p = graph_path.expect("graph planner prompt must embed the graph.json path"); + std::fs::create_dir_all(std::path::Path::new(&p).parent().unwrap()).unwrap(); + std::fs::write(&p, &body).unwrap(); + let _ = req.result_tx.send(SubagentResult { + success: true, + output: StdArc::from("Done"), + subagent_id: req.id.clone(), + child_session_id: req.id.clone(), + ..Default::default() + }); + } + } + }); + (tx, spawn_count) +} + +/// Actor with the graph harness fully armed: goal harness on, graph flag +/// on, per-node goal planner OFF (nodes need no plan file in these +/// tests), classifier disabled via `ENV_FLAG=0` at each test site. +async fn make_graph_actor( + coordinator_tx: tokio::sync::mpsc::UnboundedSender, +) -> ( + SessionActor, + TempDir, + tokio::sync::mpsc::UnboundedReceiver, +) { + let tmp = TempDir::new().expect("tempdir"); + let (gateway_tx, _gateway_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let (persistence_tx, persistence_rx) = tokio::sync::mpsc::unbounded_channel::(); + let mut actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await; + actor.events = crate::session::events::EventTracker::new(tmp.path()); + actor.goal_enabled = true; + set_goal_harness_for_tests(&actor); + actor.goal_planner_enabled = false; + actor.goal_tracker = Arc::new(parking_lot::Mutex::new( + crate::session::goal_tracker::GoalTracker::new(tmp.path().to_path_buf()), + )); + actor.graph_enabled = true; + actor.graph_tracker = Arc::new(parking_lot::Mutex::new(GraphTracker::new( + tmp.path().to_path_buf(), + ))); + actor.tool_context.subagent_event_tx = Some(coordinator_tx); + (actor, tmp, persistence_rx) +} + +/// Drain every pending persistence message and return the payloads of +/// the `GraphModeState` ones, in order. +fn drain_graph_persistence( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Vec> { + let mut states = Vec::new(); + while let Ok(msg) = rx.try_recv() { + if let PersistenceMsg::GraphModeState(state) = msg { + states.push(state); + } + } + states +} + +/// Feed one `update_goal(completed: true)` claim and run the turn-end +/// drain — with the classifier disabled this lands the node goal in +/// `Complete` (the graph seam is then driven explicitly by each test). +async fn drive_node_goal_to_complete(actor: &SessionActor) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + *actor.goal_update_rx.borrow_mut() = Some(rx); + tx.send(envelope_for_test(UpdateGoalInput { + completed: Some(true), + message: Some("node done".into()), + blocked_reason: None, + })) + .expect("send envelope"); + drop(tx); + actor.drain_goal_updates(0, DrainPurpose::TurnEnd).await; + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::Complete), + "classifier-disabled completion must land the node goal in Complete", + ); +} + +fn node_statuses(actor: &SessionActor) -> Vec<(String, NodeStatus)> { + actor + .graph_tracker + .lock() + .snapshot() + .map(|s| s.nodes.iter().map(|n| (n.id.clone(), n.status)).collect()) + .unwrap_or_default() +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn graph_set_executes_all_nodes_serially_and_completes() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, spawn_count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, mut persistence_rx) = make_graph_actor(coord_tx).await; + + // /graph — plans, installs a→b→c+final, launches a. + let outcome = actor.setup_graph("ship the widget", None).await; + let reminder = match outcome { + graph::GraphSetupOutcome::Inference { reminder, .. } => reminder, + graph::GraphSetupOutcome::Message(msg) => panic!("expected Inference, got: {msg}"), + }; + assert!( + reminder.contains("Graph node 1/4"), + "first node reminder must carry graph position: {reminder}" + ); + assert!( + reminder.contains("do a"), + "node spec in objective: {reminder}" + ); + assert_eq!(spawn_count.load(SeqOrd::SeqCst), 1, "one planner spawn"); + { + let statuses = node_statuses(&actor); + assert_eq!(statuses.len(), 4, "3 planner nodes + final"); + assert_eq!(statuses[0].1, NodeStatus::Running); + assert_eq!(statuses[1].1, NodeStatus::Waiting); + assert_eq!(statuses[3].0, crate::session::graph_tracker::FINAL_NODE_ID); + } + assert_eq!(actor.goal_tracker.lock().status(), Some(GoalStatus::Active)); + + // Seed a node-1 plan artifact so the archive path is exercised + // (the per-node planner is off in this fixture). + let plan_path = actor.goal_tracker.lock().plan_path(); + std::fs::create_dir_all(plan_path.parent().unwrap()).unwrap(); + std::fs::write(&plan_path, "NODE1-PLAN").unwrap(); + if let Some(o) = actor.goal_tracker.lock().snapshot_mut() { + o.plan_file = Some(plan_path.clone()); + } + let node1_id = actor + .graph_tracker + .lock() + .current_node_id() + .unwrap() + .to_owned(); + + // Drive all four nodes through complete → seam → next. + for expected_next in ["Graph node 2/4", "Graph node 3/4", "Graph node 4/4"] { + drive_node_goal_to_complete(&actor).await; + let next = actor.run_graph_round_end().await; + let next = next + .unwrap_or_else(|| panic!("expected next-node reminder for {expected_next}")); + assert!( + next.contains(expected_next), + "expected {expected_next} in: {next}" + ); + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::Active), + "fresh node goal must be active" + ); + } + // Final node completes → graph Complete, engine cleared, turn ends. + drive_node_goal_to_complete(&actor).await; + let end = actor.run_graph_round_end().await; + assert!(end.is_none(), "graph finished — turn must end"); + assert_eq!( + actor.graph_tracker.lock().status(), + Some(GoalStatus::Complete) + ); + assert!( + actor.goal_tracker.lock().snapshot().is_none(), + "goal engine must be cleared after graph completion" + ); + assert!( + node_statuses(&actor) + .iter() + .all(|(_, s)| *s == NodeStatus::Achieved), + "every node achieved: {:?}", + node_statuses(&actor) + ); + // Immutable baseline v1 is the pristine pre-execution plan. + let baseline = actor.graph_tracker.lock().baseline_path(1); + let frozen: Vec = + serde_json::from_slice(&std::fs::read(&baseline).unwrap()).unwrap(); + assert_eq!(frozen.len(), 4); + assert!( + frozen.iter().all(|n| n.status == NodeStatus::Waiting), + "baseline must snapshot the plan BEFORE execution" + ); + assert_eq!(frozen[3].id, crate::session::graph_tracker::FINAL_NODE_ID); + + // Node-1 goal artifacts were archived before the engine reset. + let archived = actor + .graph_tracker + .lock() + .node_archive_dir(&node1_id) + .join("plan.md"); + assert_eq!( + std::fs::read_to_string(&archived).unwrap(), + "NODE1-PLAN", + "node artifacts must be archived before the engine resets" + ); + + // Every transition was checkpointed; the last snapshot is the + // completed graph with node 1 achieved. + let states = drain_graph_persistence(&mut persistence_rx); + assert!( + states.len() >= 5, + "one checkpoint per transition, got {}", + states.len() + ); + let last = states.last().unwrap().as_ref().expect("last state is Some"); + assert_eq!(last.status, GoalStatus::Complete); + assert!( + last.nodes.iter().all(|n| n.status == NodeStatus::Achieved), + "persisted final snapshot must show all nodes achieved" + ); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn mid_turn_completion_reaches_the_seam_via_the_goal_inactive_break() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, _prx) = make_graph_actor(coord_tx).await; + let _ = actor.setup_graph("ship the widget", None).await; + + // Classifier disabled: a MID-turn drain applies the completion + // immediately (the fast path runs before the MidTurn deferral), + // flipping the goal out of Active mid-round. + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + *actor.goal_update_rx.borrow_mut() = Some(rx); + tx.send(envelope_for_test(UpdateGoalInput { + completed: Some(true), + message: None, + blocked_reason: None, + })) + .unwrap(); + drop(tx); + actor.drain_goal_updates(0, DrainPurpose::MidTurn).await; + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::Complete), + "classifier-disabled fast path completes mid-turn" + ); + + // The in-turn loop's !goal_active break now consults the seam: + // the graph must advance to node 2 instead of stranding Active. + let next = actor + .run_graph_round_end() + .await + .expect("seam must advance the graph after a mid-turn completion"); + assert!(next.contains("Graph node 2/4"), "{next}"); + assert_eq!(node_statuses(&actor)[0].1, NodeStatus::Achieved); + assert_eq!(node_statuses(&actor)[1].1, NodeStatus::Running); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn graph_over_a_complete_goal_replaces_it_cleanly() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, _prx) = make_graph_actor(coord_tx).await; + // A standalone goal driven to Complete stays in the engine. + let _ = actor.setup_goal("old standalone goal", None).await; + let old_goal_id = actor + .goal_tracker + .lock() + .snapshot() + .unwrap() + .goal_id + .clone(); + drive_node_goal_to_complete(&actor).await; + // The Complete goal does not block /graph: the per-node engine + // reset scrubs it before node 1 launches. + match actor.setup_graph("ship the widget", None).await { + graph::GraphSetupOutcome::Inference { reminder, .. } => { + assert!(reminder.contains("Graph node 1/4"), "{reminder}"); + } + graph::GraphSetupOutcome::Message(msg) => { + panic!("expected Inference over a Complete goal, got: {msg}") + } + } + let new_goal_id = actor + .goal_tracker + .lock() + .snapshot() + .unwrap() + .goal_id + .clone(); + assert_ne!(old_goal_id, new_goal_id, "node 1 must run as a NEW goal"); + assert_eq!(actor.goal_tracker.lock().status(), Some(GoalStatus::Active)); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn graph_restore_demotes_running_node_and_resume_relaunches_it() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, _prx) = make_graph_actor(coord_tx).await; + let _ = actor.setup_graph("ship the widget", None).await; + // Node a achieved, node b running. + drive_node_goal_to_complete(&actor).await; + let _ = actor.run_graph_round_end().await; + let snapshot = actor.graph_tracker.lock().snapshot().cloned().unwrap(); + + // Simulate a process restart: restore into a fresh actor + // whose goal engine is EMPTY (goal state does not survive). + let (coord_tx2, _count2) = spawn_graph_planner_coordinator(vec![]); + let (mut restored, tmp2, _rx2) = make_graph_actor(coord_tx2).await; + restored.graph_tracker = Arc::new(parking_lot::Mutex::new( + GraphTracker::from_snapshot(tmp2.path().to_path_buf(), snapshot), + )); + assert_eq!( + restored.graph_tracker.lock().status(), + Some(GoalStatus::UserPaused), + "restore must demote Active to UserPaused" + ); + let statuses = node_statuses(&restored); + assert_eq!(statuses[0].1, NodeStatus::Achieved, "a stays achieved"); + assert_eq!( + statuses[1].1, + NodeStatus::Ready, + "running b demotes to Ready for a verifier-gated re-run" + ); + + // /graph resume relaunches node b as a fresh goal. + match restored.resume_graph().await { + graph::GraphSetupOutcome::Inference { reminder, .. } => { + assert!( + reminder.contains("Graph node 2/4"), + "resume must relaunch node b: {reminder}" + ); + } + graph::GraphSetupOutcome::Message(msg) => { + panic!("expected Inference on resume, got: {msg}") + } + } + assert_eq!( + restored.graph_tracker.lock().status(), + Some(GoalStatus::Active) + ); + assert_eq!(node_statuses(&restored)[1].1, NodeStatus::Running); + assert_eq!( + restored.goal_tracker.lock().status(), + Some(GoalStatus::Active), + "node b runs as a fresh goal" + ); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn node_budget_is_graph_remaining_and_trip_cascades() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, mut persistence_rx) = make_graph_actor(coord_tx).await; + let _ = actor.setup_graph("ship the widget", Some(500)).await; + assert_eq!( + actor.goal_tracker.lock().token_budget(), + Some(500), + "node goal must be armed with the remaining graph budget" + ); + // Trip the goal-side enforcement — the graph must trip with it. + let tripped = actor.enforce_goal_token_budget(1_000_000).await; + assert!(tripped, "spend past the budget must trip"); + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::BudgetLimited) + ); + assert_eq!( + actor.graph_tracker.lock().status(), + Some(GoalStatus::BudgetLimited), + "node budget trip IS the graph budget trip" + ); + // The in-flight node is terminally resolved — never a + // forever-Running node on a budget-dead graph. + assert_eq!(node_statuses(&actor)[0].1, NodeStatus::Failed); + assert_eq!(actor.graph_tracker.lock().current_node_id(), None); + + // A terminal graph no longer owns the engine: the user may + // start a standalone /goal, and /graph clear must NOT destroy it. + assert!(!actor.graph_owns_goal_engine()); + let _ = actor.setup_goal("fresh standalone goal", None).await; + assert_eq!(actor.goal_tracker.lock().status(), Some(GoalStatus::Active)); + let actor = StdArc::new(actor); + let _ = actor + .execute_builtin_slash_command(BuiltinAction::GraphClear) + .await; + assert!(actor.graph_tracker.lock().snapshot().is_none()); + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::Active), + "/graph clear on a terminal graph must not touch an unrelated goal" + ); + let states = drain_graph_persistence(&mut persistence_rx); + assert!( + matches!(states.last(), Some(None)), + "/graph clear must persist the tombstone (None) last" + ); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn goal_auto_pause_cascades_to_graph_at_the_chokepoint() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, _prx) = make_graph_actor(coord_tx).await; + let _ = actor.setup_graph("ship the widget", None).await; + let paused = actor + .auto_pause_goal_if_active_with_message( + GoalPauseReason::Infra, + "sampler exploded".to_owned(), + ) + .await; + assert!(paused); + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::InfraPaused) + ); + let graph_status = actor.graph_tracker.lock().status(); + assert_eq!( + graph_status, + Some(GoalStatus::InfraPaused), + "goal pause must cascade to the owning graph" + ); + let msg = actor + .graph_tracker + .lock() + .snapshot() + .and_then(|s| s.pause_message.clone()) + .unwrap_or_default(); + assert!( + msg.contains("gn-"), + "graph pause message names the node: {msg}" + ); + // The seam must NOT relaunch anything on a paused graph. + assert!(actor.run_graph_round_end().await.is_none()); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn goal_commands_are_refused_while_graph_owns_the_engine() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, mut persistence_rx) = make_graph_actor(coord_tx).await; + let _ = actor.setup_graph("ship the widget", None).await; + let actor = StdArc::new(actor); + assert!(actor.graph_owns_goal_engine()); + + // /goal pause and /goal clear must be refused, leaving the + // node goal untouched. + let _ = actor + .execute_builtin_slash_command(BuiltinAction::GoalPause) + .await; + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::Active), + "/goal pause must be refused while a graph owns the engine" + ); + let _ = actor + .execute_builtin_slash_command(BuiltinAction::GoalClear) + .await; + assert!( + actor.goal_tracker.lock().snapshot().is_some(), + "/goal clear must be refused while a graph owns the engine" + ); + + // /graph pause pauses BOTH the graph and the node goal. + let _ = actor + .execute_builtin_slash_command(BuiltinAction::GraphPause) + .await; + assert_eq!( + actor.graph_tracker.lock().status(), + Some(GoalStatus::UserPaused) + ); + assert_eq!( + actor.goal_tracker.lock().status(), + Some(GoalStatus::UserPaused), + "/graph pause must stop the node goal too" + ); + + // /graph clear drops both trackers. + let _ = actor + .execute_builtin_slash_command(BuiltinAction::GraphClear) + .await; + assert!(actor.graph_tracker.lock().snapshot().is_none()); + assert!(actor.goal_tracker.lock().snapshot().is_none()); + let states = drain_graph_persistence(&mut persistence_rx); + assert!( + matches!(states.last(), Some(None)), + "/graph clear must persist the tombstone (None) last" + ); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn planning_invalid_twice_pauses_and_resume_replans() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + // Both attempts write a self-dep graph → validation fails twice. + let (coord_tx, spawn_count) = + spawn_graph_planner_coordinator(vec![invalid_graph_json(), invalid_graph_json()]); + let (mut actor, _tmp, _prx) = make_graph_actor(coord_tx).await; + match actor.setup_graph("ship the widget", None).await { + graph::GraphSetupOutcome::Message(msg) => { + assert!( + msg.contains("failed validation twice"), + "precise failure reason surfaces: {msg}" + ); + } + graph::GraphSetupOutcome::Inference { .. } => { + panic!("invalid plan must not reach inference") + } + } + assert_eq!( + spawn_count.load(SeqOrd::SeqCst), + 2, + "exactly one validation retry" + ); + assert!( + actor + .graph_tracker + .lock() + .status() + .is_some_and(|s| s.is_paused()), + "planning failure pauses the graph" + ); + + // /graph resume re-plans; a now-valid artifact launches node 1. + let (good_tx, _good_count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + actor.tool_context.subagent_event_tx = Some(good_tx); + match actor.resume_graph().await { + graph::GraphSetupOutcome::Inference { reminder, .. } => { + assert!(reminder.contains("Graph node 1/4"), "{reminder}"); + } + graph::GraphSetupOutcome::Message(msg) => { + panic!("expected planning retry to succeed on resume: {msg}") + } + } + assert_eq!( + actor.graph_tracker.lock().status(), + Some(GoalStatus::Active) + ); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} + +#[tokio::test(flavor = "current_thread")] +#[serial] +async fn graph_status_renders_glyphs_deps_tokens_and_pause() { + unsafe { std::env::set_var(ENV_FLAG, "0") }; + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (coord_tx, _count) = spawn_graph_planner_coordinator(vec![chain_graph_json()]); + let (actor, _tmp, _prx) = make_graph_actor(coord_tx).await; + let _ = actor.setup_graph("ship the widget", Some(9_000)).await; + // Node a achieved (with cost), node b running, c waiting on b. + drive_node_goal_to_complete(&actor).await; + let _ = actor.run_graph_round_end().await; + { + let mut tracker = actor.graph_tracker.lock(); + let s = tracker.snapshot_mut().unwrap(); + s.nodes[0].tokens_used = 1_000; + s.nodes[0].rounds = 3; + } + let status = actor.graph_status_message().await; + assert!(status.contains("Graph: ship the widget"), "{status}"); + assert!(status.contains("Nodes: 1/4 achieved"), "{status}"); + assert!(status.contains("(1000 tokens, 3 rounds)"), "{status}"); + assert!(status.contains("[x]"), "achieved glyph: {status}"); + assert!(status.contains("[>]"), "running glyph: {status}"); + assert!(status.contains("[.]"), "waiting glyph: {status}"); + assert!(status.contains("(waiting on "), "dep rendering: {status}"); + assert!(status.contains("| Budget: 9000"), "{status}"); + assert!(status.contains("Current node: "), "{status}"); + + // Pause: message line appears. + let _ = actor + .auto_pause_goal_if_active_with_message( + GoalPauseReason::Infra, + "sampler exploded".to_owned(), + ) + .await; + let paused = actor.graph_status_message().await; + assert!(paused.contains("Paused: "), "{paused}"); + assert!(paused.contains("sampler exploded"), "{paused}"); + }) + .await; + unsafe { std::env::remove_var(ENV_FLAG) }; +} diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs index 7662c62..5222d2f 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/idle_resume_tests.rs @@ -241,6 +241,10 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs index e25fa30..148ed98 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/inline_auto_compact_flow_tests.rs @@ -173,6 +173,10 @@ async fn create_test_actor( "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), @@ -611,6 +615,10 @@ async fn create_test_actor_with_memory( "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), @@ -1360,6 +1368,10 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() { "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs index f2f7d49..63e45f5 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/memory_config_tests.rs @@ -235,6 +235,10 @@ async fn create_test_actor_with_memory( "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs index 2be87b1..f78ff0f 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/replay_buffer_send_update_tests.rs @@ -181,6 +181,10 @@ pub(super) async fn make_replay_send_update_fixture() -> ReplaySendUpdateFixture "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs index a2231d6..ce8b108 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/support.rs @@ -286,6 +286,10 @@ pub(crate) async fn create_test_actor_ex( "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), diff --git a/crates/codegen/kigi-shell/src/session/compaction.rs b/crates/codegen/kigi-shell/src/session/compaction.rs index 2ce480e..ab87d05 100644 --- a/crates/codegen/kigi-shell/src/session/compaction.rs +++ b/crates/codegen/kigi-shell/src/session/compaction.rs @@ -2268,6 +2268,10 @@ mod inline_auto_compact_flow_tests { "/tmp/test-session", )), )), + graph_enabled: false, + graph_tracker: Arc::new(parking_lot::Mutex::new( + crate::session::graph_tracker::GraphTracker::new(std::env::temp_dir()), + )), 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), diff --git a/crates/codegen/kigi-shell/src/session/goal_tracker.rs b/crates/codegen/kigi-shell/src/session/goal_tracker.rs index d87c34d..fe367b7 100644 --- a/crates/codegen/kigi-shell/src/session/goal_tracker.rs +++ b/crates/codegen/kigi-shell/src/session/goal_tracker.rs @@ -154,7 +154,9 @@ pub enum GoalPauseReason { } impl GoalPauseReason { - fn to_status(self) -> GoalStatus { + /// Also used by the graph tracker (`graph_tracker.rs`), which reuses + /// the goal status vocabulary for graph-level pauses. + pub(crate) fn to_status(self) -> GoalStatus { match self { Self::User => GoalStatus::UserPaused, Self::BackOff => GoalStatus::BackOffPaused, @@ -166,7 +168,8 @@ impl GoalPauseReason { /// Short, stable label stashed in the `GoalPaused` history entry's /// `detail` so the pager's Recent History distinguishes pause causes. - fn history_detail(self) -> &'static str { + /// Shared with the graph tracker's `GraphPaused` entries. + pub(crate) fn history_detail(self) -> &'static str { match self { Self::User => "user", Self::BackOff => "back_off", diff --git a/crates/codegen/kigi-shell/src/session/graph_plan.rs b/crates/codegen/kigi-shell/src/session/graph_plan.rs new file mode 100644 index 0000000..69cfa4a --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/graph_plan.rs @@ -0,0 +1,439 @@ +//! Graph planner output contract: parsing, static validation, and +//! canonicalization. +//! +//! The graph planner subagent writes a JSON file shaped as +//! `{"nodes": [{"id": "", "title": "...", "spec": "...", +//! "deps": ["", ...]}]}`. Before anything executes, the harness +//! runs the Agentproof-style static gate in [`parse_and_validate`]: +//! parse errors, empty graphs, duplicate/malformed slugs, unknown or +//! self dependencies, and cycles all fail CLOSED with a precise reason +//! (the caller retries planning once, then pauses the graph). +//! +//! Canonicalization: slugs become stable content-derived ids +//! (`gn-` of the slug) so the same planned node keeps the +//! same id across replans and across machines (line-mergeable in the +//! G4 project-level graph file), nodes are re-ordered into a +//! planner-order-stable topological order (deterministic serial +//! scheduling), and the harness appends the terminal +//! [`FINAL_NODE_ID`](super::graph_tracker::FINAL_NODE_ID) verification +//! node depending on every planner node — the whole-objective gate is +//! structural, never left to the planner's discretion. + +use super::graph_tracker::{DepKind, FINAL_NODE_ID, GraphNode, NodeDep, NodeStatus}; + +/// Hard cap on planner nodes (the prompt guides 3–10; this bound is the +/// fail-fast backstop against a runaway planner, not a target). +pub(crate) const MAX_GRAPH_NODES: usize = 24; + +/// Byte cap for reading the planner's JSON file — same defensive posture +/// as the goal nudge reader: a runaway artifact must not blow up memory. +pub(crate) const MAX_GRAPH_JSON_BYTES: u64 = 256 * 1024; + +#[derive(Debug, serde::Deserialize)] +struct PlannedGraph { + nodes: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct PlannedNode { + id: String, + title: String, + spec: String, + #[serde(default)] + deps: Vec, +} + +/// Why a planner artifact was rejected. Rendered verbatim into the +/// planning-failure pause message and the retry prompt, so each variant +/// states the fix. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum GraphPlanError { + Parse(String), + Empty, + TooManyNodes(usize), + BadSlug(String), + DuplicateSlug(String), + EmptyField { slug: String, field: &'static str }, + UnknownDep { slug: String, dep: String }, + SelfDep(String), + Cycle(Vec), + IdCollision(String, String), +} + +impl std::fmt::Display for GraphPlanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Parse(e) => write!(f, "graph JSON failed to parse: {e}"), + Self::Empty => write!(f, "graph has no nodes"), + Self::TooManyNodes(n) => { + write!(f, "graph has {n} nodes; the cap is {MAX_GRAPH_NODES}") + } + Self::BadSlug(s) => write!( + f, + "node id {s:?} is invalid: use 1-64 chars of [A-Za-z0-9_-]" + ), + Self::DuplicateSlug(s) => write!(f, "duplicate node id {s:?}"), + Self::EmptyField { slug, field } => { + write!(f, "node {slug:?} has an empty {field}") + } + Self::UnknownDep { slug, dep } => { + write!(f, "node {slug:?} depends on unknown node {dep:?}") + } + Self::SelfDep(s) => write!(f, "node {s:?} depends on itself"), + Self::Cycle(nodes) => { + write!(f, "dependency cycle among nodes: {}", nodes.join(", ")) + } + Self::IdCollision(a, b) => write!( + f, + "hash id collision between slugs {a:?} and {b:?}; rename one" + ), + } + } +} + +/// FNV-1a 32-bit over the slug, rendered as 8 lowercase hex chars. +/// Stable across builds, platforms, and Rust versions — the property +/// the project-level graph file (G4) needs for line-level merges. +fn fnv1a32_hex(s: &str) -> String { + let mut hash: u32 = 0x811c_9dc5; + for byte in s.bytes() { + hash ^= u32::from(byte); + hash = hash.wrapping_mul(0x0100_0193); + } + format!("{hash:08x}") +} + +/// Canonical node id for a planner slug. +pub(crate) fn node_id_for_slug(slug: &str) -> String { + format!("gn-{}", fnv1a32_hex(slug)) +} + +fn valid_slug(slug: &str) -> bool { + !slug.is_empty() + && slug.len() <= 64 + && slug + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// Parse, statically validate, and canonicalize a planner artifact. +/// +/// On success the returned nodes are in planner-order-stable +/// topological order, carry `gn-` hash ids (`title` is kept verbatim; +/// the slug survives only inside the id hash), all start `Waiting`, +/// and end with the harness-appended final verification node. +pub(crate) fn parse_and_validate( + json: &str, + objective: &str, +) -> Result, GraphPlanError> { + let mut planned: PlannedGraph = + serde_json::from_str(json).map_err(|e| GraphPlanError::Parse(e.to_string()))?; + if planned.nodes.is_empty() { + return Err(GraphPlanError::Empty); + } + // Dedup repeated dep entries (first occurrence kept): harmless + // planner redundancy, and the indegree seed below would otherwise + // misreport a duplicated edge as a cycle. + for node in &mut planned.nodes { + let mut seen_deps = std::collections::HashSet::new(); + node.deps.retain(|d| seen_deps.insert(d.clone())); + } + if planned.nodes.len() > MAX_GRAPH_NODES { + return Err(GraphPlanError::TooManyNodes(planned.nodes.len())); + } + + // Slug hygiene + uniqueness + non-empty payload fields. + let mut seen = std::collections::HashSet::new(); + for node in &planned.nodes { + if !valid_slug(&node.id) { + return Err(GraphPlanError::BadSlug(node.id.clone())); + } + if !seen.insert(node.id.as_str()) { + return Err(GraphPlanError::DuplicateSlug(node.id.clone())); + } + if node.title.trim().is_empty() { + return Err(GraphPlanError::EmptyField { + slug: node.id.clone(), + field: "title", + }); + } + if node.spec.trim().is_empty() { + return Err(GraphPlanError::EmptyField { + slug: node.id.clone(), + field: "spec", + }); + } + } + + // Dependency resolution. + for node in &planned.nodes { + for dep in &node.deps { + if dep == &node.id { + return Err(GraphPlanError::SelfDep(node.id.clone())); + } + if !seen.contains(dep.as_str()) { + return Err(GraphPlanError::UnknownDep { + slug: node.id.clone(), + dep: dep.clone(), + }); + } + } + } + + // Kahn's algorithm, planner-order-stable: each round takes the + // FIRST remaining zero-indegree node in planner order, so the + // serial scheduler's "first Ready in storage order" rule inherits + // the planner's intent. + let order = stable_topo_order(&planned)?; + + // Canonical ids; collisions between distinct slugs fail fast. + let mut id_of: std::collections::HashMap<&str, String> = std::collections::HashMap::new(); + let mut owner_of_id: std::collections::HashMap = std::collections::HashMap::new(); + for node in &planned.nodes { + let id = node_id_for_slug(&node.id); + if let Some(prior) = owner_of_id.insert(id.clone(), node.id.as_str()) { + return Err(GraphPlanError::IdCollision( + prior.to_owned(), + node.id.clone(), + )); + } + id_of.insert(node.id.as_str(), id); + } + + let mut nodes: Vec = order + .into_iter() + .map(|idx| { + let p = &planned.nodes[idx]; + GraphNode { + id: id_of[p.id.as_str()].clone(), + title: p.title.trim().to_owned(), + spec: p.spec.trim().to_owned(), + deps: p + .deps + .iter() + .map(|d| NodeDep { + on: id_of[d.as_str()].clone(), + kind: DepKind::Blocks, + }) + .collect(), + status: NodeStatus::Waiting, + goal_id: None, + rounds: 0, + tokens_used: 0, + failure: None, + } + }) + .collect(); + + nodes.push(final_verification_node(objective, &nodes)); + Ok(nodes) +} + +/// Planner-order-stable Kahn topological sort; `Err(Cycle)` lists the +/// slugs left when no zero-indegree node remains. +fn stable_topo_order(planned: &PlannedGraph) -> Result, GraphPlanError> { + let n = planned.nodes.len(); + let index_of: std::collections::HashMap<&str, usize> = planned + .nodes + .iter() + .enumerate() + .map(|(i, node)| (node.id.as_str(), i)) + .collect(); + let mut indegree = vec![0usize; n]; + for node in &planned.nodes { + let i = index_of[node.id.as_str()]; + indegree[i] = node.deps.len(); + } + let mut done = vec![false; n]; + let mut order = Vec::with_capacity(n); + while order.len() < n { + let Some(next) = (0..n).find(|&i| !done[i] && indegree[i] == 0) else { + let cycle: Vec = (0..n) + .filter(|&i| !done[i]) + .map(|i| planned.nodes[i].id.clone()) + .collect(); + return Err(GraphPlanError::Cycle(cycle)); + }; + done[next] = true; + order.push(next); + let slug = planned.nodes[next].id.as_str(); + for node in &planned.nodes { + if node.deps.iter().any(|d| d == slug) { + indegree[index_of[node.id.as_str()]] -= 1; + } + } + } + Ok(order) +} + +/// The harness-appended terminal gate: a normal goal whose objective is +/// to independently re-verify the WHOLE graph objective. Depends on +/// every planner node, so it is always the last schedulable node. +fn final_verification_node(objective: &str, planner_nodes: &[GraphNode]) -> GraphNode { + GraphNode { + id: FINAL_NODE_ID.to_owned(), + title: "Final verification of the overall objective".to_owned(), + spec: format!( + "Independently verify that the OVERALL objective below is fully achieved, \ + end to end, in the current state of the project. Re-run the relevant \ + builds/tests/commands yourself; do not trust prior claims. If you find a \ + gap, close it. Do not add features beyond the objective.\n\n\ + OVERALL OBJECTIVE:\n{objective}" + ), + deps: planner_nodes + .iter() + .map(|n| NodeDep { + on: n.id.clone(), + kind: DepKind::Blocks, + }) + .collect(), + status: NodeStatus::Waiting, + goal_id: None, + rounds: 0, + tokens_used: 0, + failure: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plan_json(nodes: &[(&str, &[&str])]) -> String { + let nodes: Vec = nodes + .iter() + .map(|(id, deps)| { + serde_json::json!({ + "id": id, + "title": format!("Title {id}"), + "spec": format!("Spec for {id}"), + "deps": deps, + }) + }) + .collect(); + serde_json::json!({ "nodes": nodes }).to_string() + } + + #[test] + fn valid_plan_canonicalizes_topologically_and_appends_final_node() { + // Planner order deliberately lists a dependent before its dep. + let json = plan_json(&[("b", &["a"]), ("a", &[]), ("c", &["a", "b"])]); + let nodes = parse_and_validate(&json, "ship the feature").unwrap(); + assert_eq!(nodes.len(), 4); + let ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect(); + // a before b before c; final last. + assert_eq!(ids[0], node_id_for_slug("a")); + assert_eq!(ids[1], node_id_for_slug("b")); + assert_eq!(ids[2], node_id_for_slug("c")); + assert_eq!(ids[3], FINAL_NODE_ID); + // Final node depends on all three, and carries the objective. + assert_eq!(nodes[3].deps.len(), 3); + assert!(nodes[3].spec.contains("ship the feature")); + // Deps rewritten to canonical ids. + assert_eq!(nodes[1].deps[0].on, node_id_for_slug("a")); + } + + #[test] + fn ids_are_stable_content_hashes() { + assert_eq!(node_id_for_slug("auth-flow"), node_id_for_slug("auth-flow")); + assert_ne!(node_id_for_slug("auth-flow"), node_id_for_slug("auth_flow")); + assert!(node_id_for_slug("x").starts_with("gn-")); + assert_eq!(node_id_for_slug("x").len(), 3 + 8); + } + + #[test] + fn cycle_is_rejected_with_members_listed() { + let json = plan_json(&[("a", &["b"]), ("b", &["a"]), ("c", &[])]); + match parse_and_validate(&json, "o") { + Err(GraphPlanError::Cycle(members)) => { + assert!(members.contains(&"a".to_owned())); + assert!(members.contains(&"b".to_owned())); + assert!(!members.contains(&"c".to_owned())); + } + other => panic!("expected Cycle, got {other:?}"), + } + } + + #[test] + fn structural_errors_are_precise() { + assert_eq!( + parse_and_validate(r#"{"nodes":[]}"#, "o").unwrap_err(), + GraphPlanError::Empty + ); + assert!(matches!( + parse_and_validate("not json", "o"), + Err(GraphPlanError::Parse(_)) + )); + let dup = plan_json(&[("a", &[]), ("a", &[])]); + assert_eq!( + parse_and_validate(&dup, "o").unwrap_err(), + GraphPlanError::DuplicateSlug("a".into()) + ); + let self_dep = plan_json(&[("a", &["a"])]); + assert_eq!( + parse_and_validate(&self_dep, "o").unwrap_err(), + GraphPlanError::SelfDep("a".into()) + ); + let unknown = plan_json(&[("a", &["ghost"])]); + assert_eq!( + parse_and_validate(&unknown, "o").unwrap_err(), + GraphPlanError::UnknownDep { + slug: "a".into(), + dep: "ghost".into() + } + ); + let bad = plan_json(&[("has space", &[])]); + assert_eq!( + parse_and_validate(&bad, "o").unwrap_err(), + GraphPlanError::BadSlug("has space".into()) + ); + } + + #[test] + fn empty_title_or_spec_rejected() { + let json = serde_json::json!({ + "nodes": [{"id": "a", "title": " ", "spec": "s", "deps": []}] + }) + .to_string(); + assert_eq!( + parse_and_validate(&json, "o").unwrap_err(), + GraphPlanError::EmptyField { + slug: "a".into(), + field: "title" + } + ); + } + + #[test] + fn node_cap_enforced() { + let slugs: Vec = (0..MAX_GRAPH_NODES + 1).map(|i| format!("n{i}")).collect(); + let pairs: Vec<(&str, &[&str])> = slugs.iter().map(|s| (s.as_str(), &[][..])).collect(); + let json = plan_json(&pairs); + assert_eq!( + parse_and_validate(&json, "o").unwrap_err(), + GraphPlanError::TooManyNodes(MAX_GRAPH_NODES + 1) + ); + } + + #[test] + fn planner_order_breaks_topo_ties() { + // Two independent roots: planner listed z first, so z schedules first. + let json = plan_json(&[("z", &[]), ("a", &[])]); + let nodes = parse_and_validate(&json, "o").unwrap(); + assert_eq!(nodes[0].id, node_id_for_slug("z")); + assert_eq!(nodes[1].id, node_id_for_slug("a")); + } + + /// A repeated dep entry is harmless planner redundancy: it must be + /// deduped, NOT misreported as a cycle by the indegree seed. + #[test] + fn duplicate_dep_entries_are_deduped_not_a_cycle() { + let json = plan_json(&[("a", &[]), ("b", &["a", "a"])]); + let nodes = parse_and_validate(&json, "o").unwrap(); + assert_eq!(nodes.len(), 3, "a, b, final"); + let b = &nodes[1]; + assert_eq!(b.id, node_id_for_slug("b")); + assert_eq!(b.deps.len(), 1, "duplicate edge collapsed"); + assert_eq!(b.deps[0].on, node_id_for_slug("a")); + } +} diff --git a/crates/codegen/kigi-shell/src/session/graph_planner.rs b/crates/codegen/kigi-shell/src/session/graph_planner.rs new file mode 100644 index 0000000..edb1091 --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/graph_planner.rs @@ -0,0 +1,379 @@ +//! Graph planner runner: one attempt at decomposing an objective into a +//! validated node DAG. +//! +//! Deliberately thin: the spawn plumbing (harness-internal subagent, +//! verbatim-fork, fail-open model retry) is reused from +//! [`goal_planner`](super::goal_planner) via the same +//! [`GoalPlannerSpawner`] contract; this module only swaps the template +//! and replaces "plan file exists" with "graph JSON parses and passes +//! the static DAG gate" ([`graph_plan::parse_and_validate`]). +//! +//! Outcome split (both are loud, nothing is papered over): +//! - [`GraphPlannerOutcome::Invalid`] — the planner wrote an artifact +//! that failed validation. Retryable ONCE by the caller, feeding the +//! precise validation error back as CONTEXT. +//! - [`GraphPlannerOutcome::FailClosed`] — spawn/transport/missing-file +//! failure. The caller pauses the graph; `/graph resume` retries. + +use std::path::Path; +use std::sync::Arc; + +use super::goal_planner::{ + GoalPlannerSpawner, RoleRenderedPrompt, SpawnError, parse_terminal_response, +}; +use super::goal_role_tools::RoleToolNames; +use super::graph_plan::{self, MAX_GRAPH_JSON_BYTES}; +use super::graph_tracker::GraphNode; + +const GRAPH_PLANNER_PROMPT_TEMPLATE: &str = include_str!("templates/graph_planner_prompt.md"); +pub(crate) const GRAPH_PLANNER_SUBAGENT_DESCRIPTION: &str = "graph plan writer"; + +#[derive(Debug)] +pub(crate) enum GraphPlannerOutcome { + /// Validated, canonicalized nodes (topo-ordered, final node appended). + Planned(Vec), + /// Artifact written but rejected by the static gate; retry once with + /// the reason as feedback. + Invalid { reason: String }, + /// Infrastructure/spawn failure or missing artifact; pause the graph. + FailClosed { reason: String }, +} + +pub(crate) struct GraphPlannerInputs<'a> { + pub objective: &'a str, + /// Empty on the first attempt; the previous attempt's validation + /// error on the retry. + pub feedback: &'a str, + pub graph_file: &'a Path, + pub tool_names: &'a RoleToolNames, + pub inherit_tool_names: &'a RoleToolNames, +} + +/// Run one graph-planner attempt end to end: render, spawn, read the +/// artifact (size-capped), validate, canonicalize. +pub(crate) async fn run_graph_planner( + spawner: Arc, + inputs: GraphPlannerInputs<'_>, +) -> GraphPlannerOutcome { + if let Some(parent) = inputs.graph_file.parent() + && let Err(err) = tokio::fs::create_dir_all(parent).await + { + return GraphPlannerOutcome::FailClosed { + reason: format!("failed to create graph dir {}: {err}", parent.display()), + }; + } + + let graph_file_str = inputs.graph_file.to_string_lossy(); + let with_graph_file = GRAPH_PLANNER_PROMPT_TEMPLATE.replace("{GRAPH_FILE}", &graph_file_str); + let render = |tool_names: &RoleToolNames| -> String { + let rendered = tool_names.apply(&with_graph_file); + let mut full = String::with_capacity(rendered.len() + inputs.objective.len() + 256); + full.push_str(&rendered); + full.push_str("\n\nOBJECTIVE:\n"); + full.push_str(inputs.objective); + full.push_str("\n\nCONTEXT:\n"); + full.push_str(inputs.feedback); + full.push('\n'); + full + }; + let prompt = RoleRenderedPrompt { + primary: render(inputs.tool_names), + fallback: render(inputs.inherit_tool_names), + }; + + let spawn_id = uuid::Uuid::now_v7().to_string(); + let response = match spawner.spawn_planner(&spawn_id, prompt).await { + Ok(text) => text, + Err(SpawnError::Transport(detail)) => { + return GraphPlannerOutcome::FailClosed { + reason: format!("graph planner transport error: {detail}"), + }; + } + Err(SpawnError::Runtime { message, cancelled }) => { + return GraphPlannerOutcome::FailClosed { + reason: if cancelled { + format!("graph planner aborted: {message}") + } else { + format!("graph planner runtime error: {message}") + }, + }; + } + }; + + match tokio::fs::metadata(inputs.graph_file).await { + Ok(meta) if meta.is_file() && meta.len() > 0 => { + if meta.len() > MAX_GRAPH_JSON_BYTES { + return GraphPlannerOutcome::Invalid { + reason: format!( + "graph JSON is {} bytes; the cap is {MAX_GRAPH_JSON_BYTES}", + meta.len() + ), + }; + } + } + _ => { + tracing::info!( + graph_file = %graph_file_str, + terminal_token_ok = parse_terminal_response(&response), + response_snippet = %response.chars().take(120).collect::(), + "graph planner: graph file missing or empty; failing closed", + ); + return GraphPlannerOutcome::FailClosed { + reason: "graph planner produced no graph file".to_owned(), + }; + } + } + + let json = match tokio::fs::read_to_string(inputs.graph_file).await { + Ok(json) => json, + Err(err) => { + return GraphPlannerOutcome::FailClosed { + reason: format!("failed to read graph file: {err}"), + }; + } + }; + + match graph_plan::parse_and_validate(&json, inputs.objective) { + Ok(nodes) => GraphPlannerOutcome::Planned(nodes), + Err(err) => GraphPlannerOutcome::Invalid { + reason: err.to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::goal_role_tools::tests::summary_with; + use kigi_tools::types::tool::ToolKind; + use std::path::PathBuf; + use std::sync::Mutex; + + enum MockReply { + Done, + Transport, + Runtime { cancelled: bool }, + } + + struct MockSpawner { + response: MockReply, + body: Option>, + target: PathBuf, + last_prompt: Mutex>, + } + + #[async_trait::async_trait] + impl GoalPlannerSpawner for MockSpawner { + async fn spawn_planner( + &self, + _id: &str, + prompt: RoleRenderedPrompt, + ) -> Result { + *self.last_prompt.lock().unwrap() = Some(prompt.primary.clone()); + if let Some(body) = &self.body { + std::fs::write(&self.target, body).unwrap(); + } + match &self.response { + MockReply::Done => Ok("Done".to_owned()), + MockReply::Transport => Err(SpawnError::Transport("channel closed".into())), + MockReply::Runtime { cancelled } => Err(SpawnError::Runtime { + message: "boom".into(), + cancelled: *cancelled, + }), + } + } + } + + fn tool_names() -> RoleToolNames { + RoleToolNames::from_summary(&summary_with(&[ + (ToolKind::Read, "read_file"), + (ToolKind::Search, "grep"), + (ToolKind::List, "list_files"), + (ToolKind::Write, "write"), + ])) + } + + fn tmp_graph_file(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("kigi-graph-planner-test-{name}")); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("graph.json") + } + + async fn run(spawner: MockSpawner, graph_file: &Path) -> GraphPlannerOutcome { + let names = tool_names(); + run_graph_planner( + Arc::new(spawner), + GraphPlannerInputs { + objective: "build the thing", + feedback: "", + graph_file, + tool_names: &names, + inherit_tool_names: &names, + }, + ) + .await + } + + #[tokio::test] + async fn valid_artifact_yields_canonical_nodes() { + let target = tmp_graph_file("valid"); + let _ = std::fs::remove_file(&target); + let body = serde_json::json!({ + "nodes": [ + {"id": "core", "title": "Core", "spec": "core spec", "deps": []}, + {"id": "ui", "title": "UI", "spec": "ui spec", "deps": ["core"]}, + ] + }) + .to_string(); + let spawner = MockSpawner { + response: MockReply::Done, + body: Some(body.into_bytes()), + target: target.clone(), + last_prompt: Mutex::new(None), + }; + match run(spawner, &target).await { + GraphPlannerOutcome::Planned(nodes) => { + assert_eq!(nodes.len(), 3, "2 planner nodes + appended final"); + assert_eq!(nodes[2].id, crate::session::graph_tracker::FINAL_NODE_ID); + } + other => panic!("expected Planned, got {other:?}"), + } + } + + #[tokio::test] + async fn prompt_embeds_objective_feedback_and_tool_names() { + let target = tmp_graph_file("prompt"); + let _ = std::fs::remove_file(&target); + let spawner = MockSpawner { + response: MockReply::Done, + body: None, + target: target.clone(), + last_prompt: Mutex::new(None), + }; + let prompt_cell = std::sync::Arc::new(spawner); + let names = tool_names(); + let _ = run_graph_planner( + prompt_cell.clone(), + GraphPlannerInputs { + objective: "OBJ-MARKER", + feedback: "FEEDBACK-MARKER", + graph_file: &target, + tool_names: &names, + inherit_tool_names: &names, + }, + ) + .await; + let prompt = prompt_cell.last_prompt.lock().unwrap().clone().unwrap(); + assert!(prompt.contains("OBJ-MARKER")); + assert!(prompt.contains("FEEDBACK-MARKER")); + assert!(prompt.contains(&target.to_string_lossy().into_owned())); + assert!(prompt.contains("read_file"), "placeholders rendered"); + assert!(!prompt.contains("{READ_TOOL}"), "no leftover placeholder"); + assert!(!prompt.contains("{GRAPH_FILE}"), "no leftover placeholder"); + } + + #[tokio::test] + async fn invalid_artifact_is_retryable_with_reason() { + let target = tmp_graph_file("invalid"); + let spawner = MockSpawner { + response: MockReply::Done, + body: Some(br#"{"nodes":[{"id":"a","title":"A","spec":"s","deps":["a"]}]}"#.to_vec()), + target: target.clone(), + last_prompt: Mutex::new(None), + }; + match run(spawner, &target).await { + GraphPlannerOutcome::Invalid { reason } => { + assert!(reason.contains("depends on itself"), "{reason}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[tokio::test] + async fn missing_artifact_fails_closed() { + let target = tmp_graph_file("missing"); + let _ = std::fs::remove_file(&target); + let spawner = MockSpawner { + response: MockReply::Done, + body: None, + target: target.clone(), + last_prompt: Mutex::new(None), + }; + match run(spawner, &target).await { + GraphPlannerOutcome::FailClosed { reason } => { + assert!(reason.contains("no graph file"), "{reason}"); + } + other => panic!("expected FailClosed, got {other:?}"), + } + } + + #[tokio::test] + async fn runtime_error_fails_closed() { + let target = tmp_graph_file("runtime"); + let spawner = MockSpawner { + response: MockReply::Runtime { cancelled: false }, + body: None, + target: target.clone(), + last_prompt: Mutex::new(None), + }; + match run(spawner, &target).await { + GraphPlannerOutcome::FailClosed { reason } => { + assert!(reason.contains("runtime error"), "{reason}"); + } + other => panic!("expected FailClosed, got {other:?}"), + } + } + + #[tokio::test] + async fn oversize_artifact_is_invalid_with_cap_in_reason() { + let target = tmp_graph_file("oversize"); + let mut body = vec![b'x'; (MAX_GRAPH_JSON_BYTES as usize) + 1]; + body[0] = b'{'; // content is irrelevant; the size gate fires first + let spawner = MockSpawner { + response: MockReply::Done, + body: Some(body), + target: target.clone(), + last_prompt: Mutex::new(None), + }; + match run(spawner, &target).await { + GraphPlannerOutcome::Invalid { reason } => { + assert!(reason.contains("the cap is"), "{reason}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[tokio::test] + async fn transport_error_fails_closed() { + let target = tmp_graph_file("transport"); + let spawner = MockSpawner { + response: MockReply::Transport, + body: None, + target: target.clone(), + last_prompt: Mutex::new(None), + }; + match run(spawner, &target).await { + GraphPlannerOutcome::FailClosed { reason } => { + assert!(reason.contains("transport error"), "{reason}"); + } + other => panic!("expected FailClosed, got {other:?}"), + } + } + + #[tokio::test] + async fn cancelled_runtime_error_reports_aborted() { + let target = tmp_graph_file("aborted"); + let spawner = MockSpawner { + response: MockReply::Runtime { cancelled: true }, + body: None, + target: target.clone(), + last_prompt: Mutex::new(None), + }; + match run(spawner, &target).await { + GraphPlannerOutcome::FailClosed { reason } => { + assert!(reason.contains("aborted"), "{reason}"); + } + other => panic!("expected FailClosed, got {other:?}"), + } + } +} diff --git a/crates/codegen/kigi-shell/src/session/graph_tracker.rs b/crates/codegen/kigi-shell/src/session/graph_tracker.rs new file mode 100644 index 0000000..d969e79 --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/graph_tracker.rs @@ -0,0 +1,874 @@ +//! Graph mode state machine. +//! +//! This module contains [`GraphTracker`], a pure state machine (no async +//! I/O) modeled after [`GoalTracker`](super::goal_tracker::GoalTracker). +//! The `SessionActor` owns one `GraphTracker` behind a `Mutex` and calls +//! its methods at the graph orchestration points. +//! +//! Architecture: a graph is a deterministic scheduler over goals. Each +//! node executes as one ordinary goal on the existing goal engine +//! (planner, worker loop, adversarial verifier, budget, pauses), so the +//! agentic loop lives INSIDE the node and the edges between nodes stay +//! deterministic Rust. The graph layer therefore reuses the goal +//! vocabulary — [`GoalStatus`], [`GoalPhase`], [`GoalPauseReason`] — and +//! adds only the DAG bookkeeping. +//! +//! Restore semantics: a snapshot restored from disk can never resurrect +//! as a self-driving graph. `from_snapshot` demotes `Active` to +//! `UserPaused` and any `Running`/`Verifying` node back to `Ready`; the +//! user re-arms with `/graph resume`, which re-launches the node as a +//! fresh goal (the per-node verifier gates completion, so a re-run is +//! always safe). + +use std::path::PathBuf; +use std::time::Instant; + +use super::goal_tracker::{GoalPauseReason, GoalPhase, GoalStatus}; + +/// Max retained graph-history entries; oldest dropped past the cap so a +/// long graph's snapshot stays bounded. Mirrors `GOAL_HISTORY_MAX`. +const GRAPH_HISTORY_MAX: usize = 64; + +/// Canonical id of the harness-appended terminal verification node. It +/// depends on every planner node and its goal re-verifies the OVERALL +/// objective, closing the composition gap ("every node passed but the +/// whole didn't"). +pub const FINAL_NODE_ID: &str = "gn-final"; + +// Node status / dependency kinds + +/// Lifecycle status of one graph node. Single-direction machine: +/// `Waiting -> Ready -> Running -> Achieved`, with `Failed` (node judged +/// unachievable / budget-dead) and `Blocked` (a dependency failed) as +/// terminal side exits. Retries of a node stay in `Running` across +/// rounds — the goal engine owns intra-node iteration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeStatus { + Waiting, + Ready, + Running, + /// Reserved for the live "verifier in flight" badge (G2); the G0 + /// scheduler never stores it and `from_snapshot` demotes it to + /// `Ready`. + Verifying, + Achieved, + Failed, + Blocked, +} + +impl<'de> serde::Deserialize<'de> for NodeStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::from_wire_str(&s)) + } +} + +impl NodeStatus { + /// Parse a persisted status string. Unknown values map to `Ready`: + /// a node state this shell cannot interpret must restore as + /// re-runnable work, never as silently-done or stuck. + pub fn from_wire_str(s: &str) -> Self { + match s { + "waiting" => Self::Waiting, + "ready" => Self::Ready, + "running" => Self::Running, + "verifying" => Self::Verifying, + "achieved" => Self::Achieved, + "failed" => Self::Failed, + "blocked" => Self::Blocked, + _ => Self::Ready, + } + } + + /// Terminal states the scheduler never leaves. + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Achieved | Self::Failed | Self::Blocked) + } +} + +/// Dependency edge kind. `Blocks` is the planner-authored ordering +/// dependency; `DiscoveredFrom` marks a node appended by a replan (G3) +/// pointing back at the node whose execution surfaced it. Both gate +/// scheduling identically; the kind is audit/render metadata. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DepKind { + #[default] + Blocks, + DiscoveredFrom, +} + +/// One dependency edge: this node cannot start until `on` is `Achieved`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NodeDep { + pub on: String, + #[serde(default)] + pub kind: DepKind, +} + +// GraphNode + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GraphNode { + /// Short content-derived id (`gn-`); stable across replans + /// of the same node title so cross-machine graph merges (G4) stay + /// line-mergeable. + pub id: String, + pub title: String, + /// Node-level objective — the core of the node goal's objective + /// string. Written by the graph planner. + pub spec: String, + #[serde(default)] + pub deps: Vec, + pub status: NodeStatus, + /// Goal id of the node's most recent goal instance (`None` until + /// first launch). Links the node to the goal engine's own artifacts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal_id: Option, + /// Worker rounds the node's goal consumed, recorded at node + /// completion. + #[serde(default)] + pub rounds: u32, + /// Goal-scoped tokens the node consumed, recorded at node + /// completion. + #[serde(default)] + pub tokens_used: i64, + /// Short failure detail when `status` is `Failed`/`Blocked`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, +} + +// History + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GraphEvent { + GraphCreated, + PlanningStarted, + PlanningCompleted, + PlanningFailed, + NodeStarted, + NodeAchieved, + NodeFailed, + GraphPaused, + GraphResumed, + GraphCompleted, + GraphCleared, + BudgetExceeded, + /// Forward-compat sink for history written by a newer shell. + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GraphHistoryEntry { + pub timestamp: String, + pub event: GraphEvent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +impl GraphHistoryEntry { + pub(crate) fn now(event: GraphEvent, node_id: Option, detail: Option) -> Self { + Self { + timestamp: chrono::Utc::now().to_rfc3339(), + event, + node_id, + detail, + } + } +} + +// GraphOrchestration (full persisted state) + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GraphOrchestration { + pub graph_id: String, + pub objective: String, + pub status: GoalStatus, + pub phase: GoalPhase, + /// Monotonic plan version; replans/optimizer passes (G3/G6) bump it. + /// Version 1 is the initial planner output. + #[serde(default = "default_plan_version")] + pub plan_version: u32, + /// Topological-friendly storage order (planner order + harness + /// appendix). The scheduler picks the first `Ready` node in this + /// order, so execution is deterministic. + pub nodes: Vec, + /// Node currently running as the active goal, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_node: Option, + pub created_at: String, + #[serde(default)] + pub elapsed_ms: u64, + /// Graph-level token budget; each node goal is armed with the + /// remaining share so mid-node overruns trip the goal engine's own + /// enforcement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_budget: Option, + /// Tokens consumed by completed node goals (boundary-accumulated). + #[serde(default)] + pub tokens_spent_nodes: i64, + pub history: Vec, + /// Human-readable reason set on paused/blocked transitions; cleared + /// on resume/complete (mirrors `GoalOrchestration::pause_message`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pause_message: Option, +} + +fn default_plan_version() -> u32 { + 1 +} + +// GraphTracker + +/// Pure graph-mode state machine owned by the `SessionActor`. +pub struct GraphTracker { + session_dir: PathBuf, + state: Option, + /// Wall-clock anchor for `account_elapsed`; `Some` only while the + /// graph is `Active`. Never persisted. + last_probe: Option, +} + +impl GraphTracker { + pub fn new(session_dir: PathBuf) -> Self { + Self { + session_dir, + state: None, + last_probe: None, + } + } + + /// Restore from a persisted snapshot, sanitized so it can never + /// resurrect self-driving: `Active` demotes to `UserPaused` (the + /// in-turn loop that drove it is gone) and `Running`/`Verifying` + /// nodes demote to `Ready` for a fresh, verifier-gated re-run. + pub fn from_snapshot(session_dir: PathBuf, mut snapshot: GraphOrchestration) -> Self { + if snapshot.status == GoalStatus::Active { + snapshot.status = GoalStatus::UserPaused; + snapshot.pause_message = + Some("Restored after a restart. Use /graph resume to continue.".to_owned()); + } + for node in &mut snapshot.nodes { + if matches!(node.status, NodeStatus::Running | NodeStatus::Verifying) { + node.status = NodeStatus::Ready; + } + } + snapshot.current_node = None; + let mut tracker = Self { + session_dir, + state: Some(snapshot), + last_probe: None, + }; + tracker.recompute_ready(); + tracker + } + + // Paths + + /// `/graph` — root for graph-owned state (`state.json` + /// lives here, session-scoped: one current graph per session). + pub fn graph_dir(&self) -> PathBuf { + self.session_dir.join("graph") + } + + /// Per-graph artifact root (`/graph/`), so a + /// later `/graph` in the same session can never overwrite a prior + /// graph's frozen baselines or node archives. Falls back to + /// `graph_dir` when no graph is set (callers always have one). + pub fn artifacts_dir(&self) -> PathBuf { + match self.state.as_ref() { + Some(s) => self.graph_dir().join(&s.graph_id), + None => self.graph_dir(), + } + } + + /// Immutable baseline snapshot for `version` + /// (`/graph.baseline.v{N}.json`). + pub fn baseline_path(&self, version: u32) -> PathBuf { + self.artifacts_dir() + .join(format!("graph.baseline.v{version}.json")) + } + + /// Per-node artifact archive dir (`/`). + /// Node-goal artifacts (plan.md, …) are copied here when the node + /// completes, before the goal engine is cleared for the next node. + pub fn node_archive_dir(&self, node_id: &str) -> PathBuf { + self.artifacts_dir().join(node_id) + } + + // Accessors + + pub fn snapshot(&self) -> Option<&GraphOrchestration> { + self.state.as_ref() + } + + pub fn snapshot_mut(&mut self) -> Option<&mut GraphOrchestration> { + self.state.as_mut() + } + + pub fn status(&self) -> Option { + self.state.as_ref().map(|s| s.status) + } + + pub fn is_active(&self) -> bool { + self.status() == Some(GoalStatus::Active) + } + + pub fn objective(&self) -> Option<&str> { + self.state.as_ref().map(|s| s.objective.as_str()) + } + + pub fn current_node_id(&self) -> Option<&str> { + self.state.as_ref()?.current_node.as_deref() + } + + pub fn node(&self, id: &str) -> Option<&GraphNode> { + self.state.as_ref()?.nodes.iter().find(|n| n.id == id) + } + + /// Remaining graph token budget (`None` when no budget is set). + /// Saturates at zero. + pub fn remaining_budget(&self) -> Option { + let s = self.state.as_ref()?; + let budget = s.token_budget?; + Some((budget - s.tokens_spent_nodes).max(0)) + } + + // Transitions + + /// Create a fresh graph in `Planning` phase with no nodes yet. + pub fn create_graph( + &mut self, + graph_id: String, + objective: String, + token_budget: Option, + created_at: String, + ) { + let mut state = GraphOrchestration { + graph_id, + objective, + status: GoalStatus::Active, + phase: GoalPhase::Planning, + plan_version: 1, + nodes: Vec::new(), + current_node: None, + created_at, + elapsed_ms: 0, + token_budget, + tokens_spent_nodes: 0, + history: Vec::new(), + pause_message: None, + }; + state + .history + .push(GraphHistoryEntry::now(GraphEvent::GraphCreated, None, None)); + state.history.push(GraphHistoryEntry::now( + GraphEvent::PlanningStarted, + None, + None, + )); + self.state = Some(state); + self.last_probe = Some(Instant::now()); + } + + /// Install the validated node set (planner output + harness-appended + /// final node) and move to `Executing`. Roots become `Ready`. + pub fn install_nodes(&mut self, nodes: Vec) { + let Some(state) = self.state.as_mut() else { + return; + }; + state.nodes = nodes; + state.phase = GoalPhase::Executing; + push_history( + state, + GraphHistoryEntry::now(GraphEvent::PlanningCompleted, None, None), + ); + self.recompute_ready(); + } + + /// Record a planning failure in history (the caller pauses the graph + /// with the canonical message). + pub fn record_planning_failed(&mut self, detail: String) { + if let Some(state) = self.state.as_mut() { + push_history( + state, + GraphHistoryEntry::now(GraphEvent::PlanningFailed, None, Some(detail)), + ); + } + } + + /// First `Ready` node in storage order — the deterministic serial + /// scheduling rule. + pub fn next_ready_node(&self) -> Option<&GraphNode> { + self.state + .as_ref()? + .nodes + .iter() + .find(|n| n.status == NodeStatus::Ready) + } + + /// Mark `id` as launched under goal `goal_id`. + pub fn mark_node_running(&mut self, id: &str, goal_id: String) { + let Some(state) = self.state.as_mut() else { + return; + }; + if let Some(node) = state.nodes.iter_mut().find(|n| n.id == id) { + node.status = NodeStatus::Running; + node.goal_id = Some(goal_id); + } + state.current_node = Some(id.to_owned()); + push_history( + state, + GraphHistoryEntry::now(GraphEvent::NodeStarted, Some(id.to_owned()), None), + ); + } + + /// Mark `id` achieved with its consumed rounds/tokens, clear the + /// current-node pointer, and unlock dependents. + pub fn mark_node_achieved(&mut self, id: &str, rounds: u32, tokens_used: i64) { + let Some(state) = self.state.as_mut() else { + return; + }; + if let Some(node) = state.nodes.iter_mut().find(|n| n.id == id) { + node.status = NodeStatus::Achieved; + node.rounds = rounds; + node.tokens_used = tokens_used; + } + state.tokens_spent_nodes = state.tokens_spent_nodes.saturating_add(tokens_used); + state.current_node = None; + push_history( + state, + GraphHistoryEntry::now(GraphEvent::NodeAchieved, Some(id.to_owned()), None), + ); + self.recompute_ready(); + } + + /// Mark `id` failed with `detail`, clear the current-node pointer, + /// and block every transitive dependent. The caller decides the + /// graph-level consequence (pause/block). + pub fn mark_node_failed(&mut self, id: &str, detail: String) { + let Some(state) = self.state.as_mut() else { + return; + }; + if let Some(node) = state.nodes.iter_mut().find(|n| n.id == id) { + node.status = NodeStatus::Failed; + node.failure = Some(detail.clone()); + } + state.current_node = None; + push_history( + state, + GraphHistoryEntry::now(GraphEvent::NodeFailed, Some(id.to_owned()), Some(detail)), + ); + block_dependents(state, id); + } + + /// All nodes `Achieved` — the graph's success condition. + pub fn all_achieved(&self) -> bool { + self.state.as_ref().is_some_and(|s| { + !s.nodes.is_empty() && s.nodes.iter().all(|n| n.status == NodeStatus::Achieved) + }) + } + + /// True when no node can make progress: nothing `Ready`/`Running` + /// and at least one node is not `Achieved`. + pub fn is_wedged(&self) -> bool { + self.state.as_ref().is_some_and(|s| { + !s.nodes.is_empty() + && !s.nodes.iter().all(|n| n.status == NodeStatus::Achieved) + && !s + .nodes + .iter() + .any(|n| matches!(n.status, NodeStatus::Ready | NodeStatus::Running)) + }) + } + + /// `Active -> paused-family`; `true` if the transition happened. + pub fn pause(&mut self, reason: GoalPauseReason) -> bool { + self.pause_inner(reason, None) + } + + /// Like [`Self::pause`] but records a human-readable reason. + pub fn pause_with_message(&mut self, reason: GoalPauseReason, message: String) -> bool { + self.pause_inner(reason, Some(message)) + } + + fn pause_inner(&mut self, reason: GoalPauseReason, message: Option) -> bool { + self.account_elapsed(); + let Some(state) = self.state.as_mut() else { + return false; + }; + if state.status != GoalStatus::Active { + return false; + } + state.status = reason.to_status(); + state.pause_message = message; + push_history( + state, + GraphHistoryEntry::now( + GraphEvent::GraphPaused, + state.current_node.clone(), + Some(reason.history_detail().to_owned()), + ), + ); + self.last_probe = None; + true + } + + /// `paused-family -> Active`; `true` if the transition happened. + /// Recomputes the ready set so a resume after restore re-arms roots. + pub fn resume(&mut self) -> bool { + let Some(state) = self.state.as_mut() else { + return false; + }; + if !state.status.is_paused() { + return false; + } + state.status = GoalStatus::Active; + state.pause_message = None; + push_history( + state, + GraphHistoryEntry::now(GraphEvent::GraphResumed, None, None), + ); + self.last_probe = Some(Instant::now()); + self.recompute_ready(); + true + } + + /// `Active -> Complete`; `true` if the transition happened. + pub fn complete(&mut self) -> bool { + self.account_elapsed(); + let Some(state) = self.state.as_mut() else { + return false; + }; + if state.status != GoalStatus::Active { + return false; + } + state.status = GoalStatus::Complete; + state.pause_message = None; + push_history( + state, + GraphHistoryEntry::now(GraphEvent::GraphCompleted, None, None), + ); + self.last_probe = None; + true + } + + /// `Active -> BudgetLimited`; `true` if the transition happened. + /// The in-flight node (if any) is terminally resolved to `Failed` so + /// a budget-dead graph never persists a forever-`Running` node. + pub fn budget_limit(&mut self) -> bool { + self.account_elapsed(); + let Some(state) = self.state.as_mut() else { + return false; + }; + if state.status != GoalStatus::Active { + return false; + } + let in_flight = state.current_node.clone(); + if let Some(node_id) = &in_flight + && let Some(node) = state.nodes.iter_mut().find(|n| n.id == *node_id) + && !node.status.is_terminal() + { + node.status = NodeStatus::Failed; + node.failure = Some("graph token budget exhausted".to_owned()); + } + state.current_node = None; + state.status = GoalStatus::BudgetLimited; + state.pause_message = None; + push_history( + state, + GraphHistoryEntry::now(GraphEvent::BudgetExceeded, in_flight, None), + ); + self.last_probe = None; + true + } + + /// Drop all graph state (history records the clear first so a + /// final persisted snapshot, if any, carries it). + pub fn clear(&mut self) { + if let Some(state) = self.state.as_mut() { + push_history( + state, + GraphHistoryEntry::now(GraphEvent::GraphCleared, None, None), + ); + } + self.state = None; + self.last_probe = None; + } + + /// Fold the wall-clock delta since the last probe into + /// `elapsed_ms`. No-op unless `Active`. + pub fn account_elapsed(&mut self) { + let Some(state) = self.state.as_mut() else { + return; + }; + if state.status != GoalStatus::Active { + return; + } + let now = Instant::now(); + if let Some(probe) = self.last_probe { + state.elapsed_ms = state + .elapsed_ms + .saturating_add(now.duration_since(probe).as_millis() as u64); + } + self.last_probe = Some(now); + } + + pub fn append_history(&mut self, entry: GraphHistoryEntry) { + if let Some(state) = self.state.as_mut() { + push_history(state, entry); + } + } + + /// Promote every `Waiting` node whose deps are all `Achieved` to + /// `Ready`. + pub fn recompute_ready(&mut self) { + let Some(state) = self.state.as_mut() else { + return; + }; + let achieved: std::collections::HashSet = state + .nodes + .iter() + .filter(|n| n.status == NodeStatus::Achieved) + .map(|n| n.id.clone()) + .collect(); + for node in &mut state.nodes { + if node.status == NodeStatus::Waiting + && node.deps.iter().all(|d| achieved.contains(&d.on)) + { + node.status = NodeStatus::Ready; + } + } + } +} + +/// Append with the history cap (oldest dropped). +fn push_history(state: &mut GraphOrchestration, entry: GraphHistoryEntry) { + state.history.push(entry); + if state.history.len() > GRAPH_HISTORY_MAX { + let overflow = state.history.len() - GRAPH_HISTORY_MAX; + state.history.drain(..overflow); + } +} + +/// Mark every transitive dependent of `failed_id` as `Blocked` (only +/// non-terminal nodes; an already-achieved dependent stays achieved). +fn block_dependents(state: &mut GraphOrchestration, failed_id: &str) { + let mut blocked: std::collections::HashSet = std::collections::HashSet::new(); + blocked.insert(failed_id.to_owned()); + // Fixed-point pass; node count is small (planner-capped), so the + // quadratic sweep is simpler than building an adjacency index. + loop { + let mut changed = false; + for node in &mut state.nodes { + if node.status.is_terminal() || blocked.contains(&node.id) { + continue; + } + if node.deps.iter().any(|d| blocked.contains(&d.on)) { + node.status = NodeStatus::Blocked; + node.failure = Some(format!("blocked: dependency chain failed at {failed_id}")); + blocked.insert(node.id.clone()); + changed = true; + } + } + if !changed { + return; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(id: &str, deps: &[&str]) -> GraphNode { + GraphNode { + id: id.to_owned(), + title: id.to_owned(), + spec: format!("do {id}"), + deps: deps + .iter() + .map(|d| NodeDep { + on: (*d).to_owned(), + kind: DepKind::Blocks, + }) + .collect(), + status: NodeStatus::Waiting, + goal_id: None, + rounds: 0, + tokens_used: 0, + failure: None, + } + } + + fn tracker_with(nodes: Vec) -> GraphTracker { + let mut t = GraphTracker::new(std::env::temp_dir()); + t.create_graph( + "g1".into(), + "objective".into(), + None, + "2026-07-20T00:00:00Z".into(), + ); + t.install_nodes(nodes); + t + } + + #[test] + fn install_promotes_roots_to_ready_in_storage_order() { + let t = tracker_with(vec![node("a", &[]), node("b", &["a"]), node("c", &[])]); + assert_eq!(t.node("a").unwrap().status, NodeStatus::Ready); + assert_eq!(t.node("b").unwrap().status, NodeStatus::Waiting); + assert_eq!(t.node("c").unwrap().status, NodeStatus::Ready); + // Deterministic serial rule: first Ready in storage order. + assert_eq!(t.next_ready_node().unwrap().id, "a"); + } + + #[test] + fn achieved_unlocks_dependents_and_accumulates_tokens() { + let mut t = tracker_with(vec![node("a", &[]), node("b", &["a"])]); + t.mark_node_running("a", "goal-1".into()); + assert_eq!(t.current_node_id(), Some("a")); + t.mark_node_achieved("a", 3, 1_000); + assert_eq!(t.current_node_id(), None); + assert_eq!(t.node("b").unwrap().status, NodeStatus::Ready); + assert_eq!(t.snapshot().unwrap().tokens_spent_nodes, 1_000); + t.mark_node_running("b", "goal-2".into()); + t.mark_node_achieved("b", 1, 500); + assert!(t.all_achieved()); + assert_eq!(t.snapshot().unwrap().tokens_spent_nodes, 1_500); + } + + #[test] + fn failed_node_blocks_transitive_dependents_only() { + let mut t = tracker_with(vec![ + node("a", &[]), + node("b", &["a"]), + node("c", &["b"]), + node("d", &[]), + ]); + t.mark_node_running("a", "goal-1".into()); + t.mark_node_failed("a", "unachievable".into()); + assert_eq!(t.node("a").unwrap().status, NodeStatus::Failed); + assert_eq!(t.node("b").unwrap().status, NodeStatus::Blocked); + assert_eq!(t.node("c").unwrap().status, NodeStatus::Blocked); + // Independent chain keeps going. + assert_eq!(t.node("d").unwrap().status, NodeStatus::Ready); + assert!(!t.is_wedged()); + t.mark_node_running("d", "goal-2".into()); + t.mark_node_achieved("d", 1, 10); + assert!(t.is_wedged(), "no runnable node left, one chain dead"); + assert!(!t.all_achieved()); + } + + #[test] + fn remaining_budget_saturates_and_tracks_node_spend() { + let mut t = GraphTracker::new(std::env::temp_dir()); + t.create_graph("g".into(), "o".into(), Some(1_000), "t".into()); + t.install_nodes(vec![node("a", &[])]); + assert_eq!(t.remaining_budget(), Some(1_000)); + t.mark_node_running("a", "goal-1".into()); + t.mark_node_achieved("a", 1, 1_500); + assert_eq!(t.remaining_budget(), Some(0), "saturates at zero"); + } + + #[test] + fn restore_demotes_active_and_running_for_safe_resume() { + let mut t = tracker_with(vec![node("a", &[]), node("b", &["a"])]); + t.mark_node_running("a", "goal-1".into()); + let snapshot = t.snapshot().unwrap().clone(); + let restored = GraphTracker::from_snapshot(std::env::temp_dir(), snapshot); + let s = restored.snapshot().unwrap(); + assert_eq!(s.status, GoalStatus::UserPaused); + assert!( + s.pause_message + .as_deref() + .unwrap() + .contains("/graph resume") + ); + assert_eq!(s.current_node, None); + assert_eq!( + restored.node("a").unwrap().status, + NodeStatus::Ready, + "running node re-runs, verifier gates completion" + ); + } + + #[test] + fn pause_resume_round_trip_recomputes_ready() { + let mut t = tracker_with(vec![node("a", &[]), node("b", &["a"])]); + assert!(t.pause(GoalPauseReason::User)); + assert_eq!(t.status(), Some(GoalStatus::UserPaused)); + assert!(!t.pause(GoalPauseReason::User), "pause is Active-only"); + // Simulate an externally-restored snapshot where `a` is already + // Achieved but `b` was never promoted: resume() must recompute. + if let Some(s) = t.snapshot_mut() { + s.nodes[0].status = NodeStatus::Achieved; + } + assert_eq!(t.node("b").unwrap().status, NodeStatus::Waiting); + assert!(t.resume()); + assert_eq!(t.status(), Some(GoalStatus::Active)); + assert_eq!( + t.node("b").unwrap().status, + NodeStatus::Ready, + "resume must promote unlocked Waiting nodes" + ); + assert_eq!(t.next_ready_node().unwrap().id, "b"); + } + + #[test] + fn budget_limit_terminally_fails_the_running_node() { + let mut t = GraphTracker::new(std::env::temp_dir()); + t.create_graph("g".into(), "o".into(), Some(10), "t".into()); + t.install_nodes(vec![node("a", &[]), node("b", &["a"])]); + t.mark_node_running("a", "goal-1".into()); + assert!(t.budget_limit()); + assert_eq!(t.status(), Some(GoalStatus::BudgetLimited)); + let s = t.snapshot().unwrap(); + assert_eq!( + s.nodes[0].status, + NodeStatus::Failed, + "a budget-dead graph must not persist a forever-Running node" + ); + assert!( + s.nodes[0] + .failure + .as_deref() + .unwrap() + .contains("budget exhausted") + ); + assert_eq!(s.current_node, None); + } + + #[test] + fn unknown_node_status_restores_as_ready() { + assert_eq!(NodeStatus::from_wire_str("half_done"), NodeStatus::Ready); + assert_eq!(NodeStatus::from_wire_str("achieved"), NodeStatus::Achieved); + } + + #[test] + fn history_is_capped_dropping_the_oldest() { + let mut t = tracker_with(vec![node("a", &[])]); + for i in 0..(GRAPH_HISTORY_MAX + 10) { + t.append_history(GraphHistoryEntry::now( + GraphEvent::Unknown, + None, + Some(format!("e{i}")), + )); + } + let history = &t.snapshot().unwrap().history; + assert_eq!(history.len(), GRAPH_HISTORY_MAX); + // Setup pushed 3 entries (created/planning-started/completed) and + // the loop 74 more; overflow 13 drops the setup entries + e0..e9, + // so the oldest survivor is e10 and the newest is e73. + assert_eq!(history.first().unwrap().detail.as_deref(), Some("e10")); + assert_eq!(history.last().unwrap().detail.as_deref(), Some("e73")); + } +} diff --git a/crates/codegen/kigi-shell/src/session/mod.rs b/crates/codegen/kigi-shell/src/session/mod.rs index 398434e..079de8c 100644 --- a/crates/codegen/kigi-shell/src/session/mod.rs +++ b/crates/codegen/kigi-shell/src/session/mod.rs @@ -301,6 +301,9 @@ pub(crate) mod goal_stop_detector; pub(crate) mod goal_strategist; pub(crate) mod goal_summarizer; pub mod goal_tracker; +pub(crate) mod graph_plan; +pub(crate) mod graph_planner; +pub mod graph_tracker; pub mod helpers; pub(crate) mod image_describe; pub(crate) mod image_normalize; diff --git a/crates/codegen/kigi-shell/src/session/persistence.rs b/crates/codegen/kigi-shell/src/session/persistence.rs index 4fa30c4..ca4cab7 100644 --- a/crates/codegen/kigi-shell/src/session/persistence.rs +++ b/crates/codegen/kigi-shell/src/session/persistence.rs @@ -335,6 +335,10 @@ pub enum PersistenceMsg { AnnouncementState(crate::session::announcement_state::AnnouncementState), /// Persist goal mode orchestration state. GoalModeState(crate::session::goal_tracker::GoalOrchestration), + /// Persist graph mode orchestration state; `None` tombstones the + /// state file after `/graph clear` so a cleared graph can never + /// resurrect on session restore. + GraphModeState(Option), /// Persist a local feedback entry (user feedback) Feedback(LocalFeedbackEntry), /// Persist a /btw side question entry @@ -1592,6 +1596,15 @@ impl SessionPersistence { tracing::warn!(?e, "failed to write goal mode state"); } } + PersistenceMsg::GraphModeState(state) => { + if let Err(e) = self + .storage + .write_graph_mode_state(&self.info, state.as_ref()) + .await + { + tracing::warn!(?e, "failed to write graph mode state"); + } + } PersistenceMsg::ContentChunk(content_chunks) => { let content_part = content_chunks .content_chunks @@ -2095,6 +2108,8 @@ pub struct PersistedInfoLight { pub announcement_state: Option, /// Persisted goal mode orchestration state (None for sessions without goal mode) pub goal_mode_state: Option, + /// Persisted graph mode orchestration state (None for sessions without graph mode) + pub graph_mode_state: Option, } /// Loads a session for streaming updates without reading them into memory. @@ -2131,6 +2146,7 @@ pub(crate) async fn load_light( signals: persisted.signals, announcement_state: persisted.announcement_state, goal_mode_state: persisted.goal_mode_state, + graph_mode_state: persisted.graph_mode_state, }; let (tx, rx) = mpsc::unbounded_channel::(); diff --git a/crates/codegen/kigi-shell/src/session/slash_commands.rs b/crates/codegen/kigi-shell/src/session/slash_commands.rs index 0531515..b1677b5 100644 --- a/crates/codegen/kigi-shell/src/session/slash_commands.rs +++ b/crates/codegen/kigi-shell/src/session/slash_commands.rs @@ -42,6 +42,10 @@ pub(crate) enum BuiltinGate { Hooks, Plugins, Goal, + /// `resolve_graph()` feature flag is on AND the goal harness is + /// available (graph nodes execute as goals, so `/graph` needs + /// everything `/goal` needs). + Graph, } /// All built-in slash commands. Order here = display order in autocomplete. @@ -256,6 +260,31 @@ pub(super) const BUILTIN_COMMANDS: &[BuiltinCommand] = &[ } }, }, + BuiltinCommand { + name: "graph", + description: "Decompose an objective into a dependency graph of autonomous goals", + argument_hint: Some(" [--budget ] | status | pause | resume | clear"), + aliases: &[], + gate: BuiltinGate::Graph, + resolve: |args| { + let trimmed = args.trim(); + match trimmed.to_lowercase().as_str() { + // `show` upgrades to a rendered DAG view in G5; until then + // it is an alias for the status tree. + "" | "status" | "show" => BuiltinAction::GraphStatus, + "pause" => BuiltinAction::GraphPause, + "resume" => BuiltinAction::GraphResume, + "clear" => BuiltinAction::GraphClear, + _ => { + let (objective, token_budget) = parse_goal_budget(trimmed); + BuiltinAction::GraphSet { + objective, + token_budget, + } + } + } + }, + }, ]; /// Split a trailing `--budget ` flag off a `/goal` objective. @@ -387,6 +416,9 @@ pub(crate) struct CommandAvailability { pub hooks: bool, pub plugins: bool, pub goal: bool, + /// `/graph` gate: the graph feature flag AND the goal harness (nodes + /// execute as goals) are both available. + pub graph: bool, } impl CommandAvailability { @@ -401,6 +433,7 @@ impl CommandAvailability { BuiltinGate::Hooks => self.hooks, BuiltinGate::Plugins => self.plugins, BuiltinGate::Goal => self.goal, + BuiltinGate::Graph => self.graph, } } @@ -416,6 +449,7 @@ impl CommandAvailability { hooks: true, plugins: true, goal: true, + graph: true, } } } @@ -660,6 +694,14 @@ pub(super) enum BuiltinAction { GoalPause, GoalResume, GoalClear, + GraphSet { + objective: String, + token_budget: Option, + }, + GraphStatus, + GraphPause, + GraphResume, + GraphClear, } impl BuiltinAction { @@ -692,6 +734,11 @@ impl BuiltinAction { | BuiltinAction::GoalPause | BuiltinAction::GoalResume | BuiltinAction::GoalClear => "goal", + BuiltinAction::GraphSet { .. } + | BuiltinAction::GraphStatus + | BuiltinAction::GraphPause + | BuiltinAction::GraphResume + | BuiltinAction::GraphClear => "graph", } } @@ -724,6 +771,11 @@ impl BuiltinAction { | BuiltinAction::GoalPause | BuiltinAction::GoalResume | BuiltinAction::GoalClear => false, + BuiltinAction::GraphSet { .. } => true, + BuiltinAction::GraphStatus + | BuiltinAction::GraphPause + | BuiltinAction::GraphResume + | BuiltinAction::GraphClear => false, } } } @@ -1523,6 +1575,7 @@ mod tests { "session-info", "feedback", "goal", + "graph", "loop", "commit", "deploy", @@ -1606,6 +1659,69 @@ mod tests { assert!(!names.iter().any(|n| n == "goal"), "got: {names:?}"); } + #[test] + fn availability_filters_graph_command() { + let names = advertised_names(CommandAvailability { + graph: false, + ..CommandAvailability::all_enabled() + }); + assert!(!names.iter().any(|n| n == "graph"), "got: {names:?}"); + } + + #[test] + fn graph_does_not_resolve_when_gate_off() { + let availability = CommandAvailability { + graph: false, + ..CommandAvailability::all_enabled() + }; + assert!( + resolve( + vec![text_block("/graph status")], + &[], + availability, + SkillSlashRewrite::default(), + ) + .is_ok(), + "expected pass-through (Ok), got an outcome", + ); + } + + #[test] + fn graph_resolves_subcommands_and_budget() { + let set = resolve_builtin("graph", "ship the feature --budget 5000") + .expect("/graph must resolve"); + match set { + BuiltinAction::GraphSet { + objective, + token_budget, + } => { + assert_eq!(objective, "ship the feature"); + assert_eq!(token_budget, Some(5000)); + } + other => panic!("expected GraphSet, got /{}", other.command_name()), + } + assert!(matches!( + resolve_builtin("graph", ""), + Some(BuiltinAction::GraphStatus) + )); + assert!(matches!( + resolve_builtin("graph", "show"), + Some(BuiltinAction::GraphStatus) + )); + assert!(matches!( + resolve_builtin("graph", "pause"), + Some(BuiltinAction::GraphPause) + )); + assert!(matches!( + resolve_builtin("graph", "resume"), + Some(BuiltinAction::GraphResume) + )); + assert!(matches!( + resolve_builtin("graph", "clear"), + Some(BuiltinAction::GraphClear) + )); + } + #[test] fn goal_does_not_resolve_when_update_goal_unavailable() { let availability = CommandAvailability { @@ -1718,6 +1834,7 @@ mod tests { "memory", "feedback", "goal", + "graph", "hooks-list", "plugins", "reload-plugins", @@ -2074,6 +2191,7 @@ mod tests { "dream", "feedback", "goal", + "graph", "loop", "hooks-list", "hooks-trust", diff --git a/crates/codegen/kigi-shell/src/session/storage/jsonl/mod.rs b/crates/codegen/kigi-shell/src/session/storage/jsonl/mod.rs index c3a6cb8..4580276 100644 --- a/crates/codegen/kigi-shell/src/session/storage/jsonl/mod.rs +++ b/crates/codegen/kigi-shell/src/session/storage/jsonl/mod.rs @@ -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 { let summary = self.read_summary_sync(info)?; let chat_history = @@ -1116,6 +1142,10 @@ impl StorageAdapter for JsonlStorageAdapter { .read_optional_json_sync::( &self.goal_mode_state_file(info), )?; + let graph_mode_state = self + .read_optional_json_sync::( + &self.graph_mode_state_file(info), + )?; let rewind_points = self.read_jsonl::(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::( &self.goal_mode_state_file(info), )?; + let graph_mode_state = self + .read_optional_json_sync::( + &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(), diff --git a/crates/codegen/kigi-shell/src/session/storage/jsonl/tests.rs b/crates/codegen/kigi-shell/src/session/storage/jsonl/tests.rs index a30b210..cead051 100644 --- a/crates/codegen/kigi-shell/src/session/storage/jsonl/tests.rs +++ b/crates/codegen/kigi-shell/src/session/storage/jsonl/tests.rs @@ -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"); +} diff --git a/crates/codegen/kigi-shell/src/session/storage/mod.rs b/crates/codegen/kigi-shell/src/session/storage/mod.rs index 73c2d38..0e54ae4 100644 --- a/crates/codegen/kigi-shell/src/session/storage/mod.rs +++ b/crates/codegen/kigi-shell/src/session/storage/mod.rs @@ -271,6 +271,8 @@ pub struct PersistedData { pub announcement_state: Option, /// Persisted goal mode orchestration state (None for sessions without goal mode) pub goal_mode_state: Option, + /// Persisted graph mode orchestration state (None for sessions without graph mode) + pub graph_mode_state: Option, } /// Persisted data WITHOUT updates - for memory-efficient session loading @@ -288,6 +290,8 @@ pub struct PersistedDataLight { pub announcement_state: Option, /// Persisted goal mode orchestration state (None for sessions without goal mode) pub goal_mode_state: Option, + /// Persisted graph mode orchestration state (None for sessions without graph mode) + pub graph_mode_state: Option, } /// 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; diff --git a/crates/codegen/kigi-shell/src/session/templates/graph_planner_prompt.md b/crates/codegen/kigi-shell/src/session/templates/graph_planner_prompt.md new file mode 100644 index 0000000..d3ed90e --- /dev/null +++ b/crates/codegen/kigi-shell/src/session/templates/graph_planner_prompt.md @@ -0,0 +1,70 @@ +You are the Graph Plan Writer for the Kigi harness. You run ONCE at graph +creation. Decompose the objective into a SMALL dependency graph (a DAG) of +nodes. Each node later executes as its own autonomous goal — with its own +plan, implementation loop, and adversarial verification — so every node must +be a coherent, independently completable, independently verifiable unit of +work. The user never sees this file — write for the harness. + +## Inputs (below this prompt) + +- OBJECTIVE: the user's overall objective, verbatim. +- CONTEXT: optional extra snippet (usually empty; on a retry it carries the + validation errors your previous output failed — fix exactly those). + Parent implementer history arrives as a forked conversation prefix + (``), not here. + +Inspect the workspace with your `{READ_TOOL}`/`{SEARCH_TOOL}`/`{LIST_TOOL}` +tools to ground the decomposition in what actually exists. Do NOT modify the +workspace; your only write is `{GRAPH_FILE}`. + +## Decomposition rules + +- 2-8 nodes, each sized to be completable in one focused autonomous run. + Prefer FEWER, larger nodes over many fragments: every node pays a full + plan + verify cycle. +- A dependency means "this node CANNOT EVEN START until that node is + Achieved". Only true ordering constraints — a false dependency serializes + work that could run independently. Independent nodes simply omit deps. +- Do NOT add a final whole-objective verification node: the harness appends + one automatically, depending on every node you write. +- Each `spec` is an OUTCOME contract for that node alone, in the OBJECTIVE's + own vocabulary: what must observably exist/hold when the node is done, + never how to structure the code. The node's own planner will derive + acceptance criteria from it — give it enough precision to do so. +- Preserve the OBJECTIVE's must-have terms verbatim across the specs; never + swap a named technique, technology, or artifact for an easier one. +- Scope the union of all specs to exactly the OBJECTIVE: no invented scope, + and no silently dropped requirement — every OBJECTIVE requirement must be + covered by exactly one node's spec. + +## Output contract — STRICT + +Use your `{WRITE_TOOL}` tool to write JSON to `{GRAPH_FILE}` with EXACTLY +this shape (no comments, no trailing commas, no extra keys): + +``` +{ + "nodes": [ + { + "id": "short-kebab-slug", + "title": "One-line human title", + "spec": "Outcome contract for this node alone.", + "deps": ["slug-of-prerequisite"] + } + ] +} +``` + +- `id`: unique per node, 1-64 chars of `[A-Za-z0-9_-]`. +- `deps`: ids of other nodes in this file; omit or use `[]` for roots; no + self-references, no cycles. +- List nodes in the order work would naturally proceed; the harness breaks + scheduling ties by your order. + +Your terminal response must be exactly: + +``` +Done +``` + +No other text — the harness parses this token to detect completion.