Add /graph G0: serial graph engineering mode over the goal engine
A deterministic DAG scheduler layered on the existing goal engine: /graph <objective> decomposes the objective via a graph-planner subagent, gates the result through Agentproof-style static validation (cycles, unknown deps, duplicate slugs, caps), appends a structural final-verification node, then executes each node as one ordinary goal — planner, worker loop, adversarial verifier, budget and pause machinery all reused verbatim. The in-turn loop advances nodes within the same turn (multi-loop closed loop); goal-side auto-pauses cascade to the graph at a single chokepoint; node goals are armed with the remaining graph budget so mid-node overruns trip graph-wide. Gated by KIGI_GRAPH=1 (default off) + the goal harness. State persists to <session_dir>/graph/state.json with a clear-tombstone; per-version immutable baselines and per-node artifact archives live under graph/<graph_id>/. Restore demotes Active->UserPaused and Running->Ready (verifier-gated re-run). Review hardening (adversarial multi-agent pass, 24 confirmed findings fixed): the goal-inactive loop break now consults the graph seam (mid-turn classifier-disabled completions can no longer strand an Active graph), the pause cascade fires even when no node goal is in flight (cancel during planning), node bookkeeping precedes the long setup await, /graph clear only resets the engine it owns, budget trips terminally fail the in-flight node, and the pause transitions in /goal pause + /graph pause no longer hide inside debug_assert! (a release-build no-op inherited from upstream). Tests: 4908 kigi-shell lib tests green, including a serial 4-node closed-loop e2e, restore/resume, cascade, mutual-exclusion, planning-retry, persistence round-trip + tombstone, and status rendering.
This commit is contained in:
@@ -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
|
||||
<objective>` 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<..>)` →
|
||||
`<session_dir>/graph/state.json` (`None` tombstones after clear);
|
||||
immutable per-version baselines `graph/graph.baseline.v{N}.json`;
|
||||
per-node goal artifacts archived to `graph/<node_id>/`. 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/
|
||||
|
||||
@@ -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<bool> {
|
||||
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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -120,6 +120,7 @@ pub(crate) struct SessionSpawnOptions<'a> {
|
||||
pub persisted_signals: Option<crate::session::signals::SessionSignals>,
|
||||
pub persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||
pub persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
pub persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<parking_lot::Mutex<crate::session::goal_tracker::GoalTracker>>,
|
||||
/// 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<parking_lot::Mutex<crate::session::graph_tracker::GraphTracker>>,
|
||||
/// `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)]
|
||||
|
||||
@@ -1904,6 +1904,19 @@ impl SessionActor {
|
||||
tokens_used,
|
||||
finished_marginal,
|
||||
);
|
||||
// Graph cascade: node goals are armed with the REMAINING graph
|
||||
// budget, so a node-level budget trip is the graph-level trip.
|
||||
if self.graph_harness_enabled() && self.graph_tracker.lock().is_active() {
|
||||
tracing::warn!("graph: node goal budget trip cascades to graph BudgetLimited");
|
||||
self.graph_tracker.lock().budget_limit();
|
||||
self.persist_graph_state();
|
||||
self.send_slash_command_output(&format!(
|
||||
"Graph token budget reached ({tokens_used} tokens this node) — graph \
|
||||
stopped. Use /graph clear, then /graph <objective> to start a new one."
|
||||
))
|
||||
.await;
|
||||
return true;
|
||||
}
|
||||
self.send_slash_command_output(&format!(
|
||||
"Goal token budget reached ({tokens_used} of {budget} tokens) — goal \
|
||||
stopped. Use /goal clear, then /goal <objective> to start a new one."
|
||||
@@ -2265,18 +2278,21 @@ impl SessionActor {
|
||||
message: Option<String>,
|
||||
) -> bool {
|
||||
let current_tokens = self.chat_state_handle.get_total_tokens().await as i64;
|
||||
{
|
||||
let graph_message = message.clone();
|
||||
let goal_paused = {
|
||||
let mut tracker = self.goal_tracker.lock();
|
||||
if tracker.status() != Some(crate::session::goal_tracker::GoalStatus::Active) {
|
||||
return false;
|
||||
}
|
||||
// The early-return above guarantees `Active`, so the pause
|
||||
// transition always succeeds here.
|
||||
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
|
||||
}
|
||||
};
|
||||
if goal_paused {
|
||||
self.clear_pending_classifier_completions();
|
||||
let (tokens_used, finished_marginal) = self.goal_tokens(current_tokens);
|
||||
let notify = self.goal_notify_sender();
|
||||
@@ -2288,7 +2304,31 @@ impl SessionActor {
|
||||
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
|
||||
|
||||
@@ -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 <objective>` 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 <objective>` — 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<i64>,
|
||||
) -> 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 <objective>."
|
||||
.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<Vec<GraphNode>, 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<dyn GoalPlannerSpawner> = 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<String> {
|
||||
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 <objective>.",
|
||||
)
|
||||
.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<String> {
|
||||
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<String> {
|
||||
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<std::path::PathBuf> = 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 <objective> 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 <objective> to start a new one.".to_owned(),
|
||||
),
|
||||
Some(GoalStatus::BudgetLimited) => GraphSetupOutcome::Message(
|
||||
"Graph is budget-limited. Use /graph clear, then /graph <objective>.".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<Option<String>, 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 <objective> 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ pub(crate) async fn spawn_session_actor(
|
||||
persisted_signals: Option<crate::session::signals::SessionSignals>,
|
||||
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
memory_config: Option<crate::config::MemoryConfig>,
|
||||
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<crate::session::signals::SessionSignals>,
|
||||
persisted_plan_mode: Option<crate::session::plan_mode::PlanModeSnapshot>,
|
||||
persisted_goal_mode: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
persisted_graph_mode: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
persisted_announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
memory_config: Option<crate::config::MemoryConfig>,
|
||||
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,
|
||||
|
||||
@@ -305,10 +305,28 @@ 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 {
|
||||
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)]
|
||||
@@ -317,6 +335,30 @@ impl SessionActor {
|
||||
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)]
|
||||
}
|
||||
super::graph::GraphSetupOutcome::Message(msg) => {
|
||||
self.send_slash_command_output(&msg).await;
|
||||
return ok_end_turn(0, None);
|
||||
}
|
||||
},
|
||||
_ => return self.execute_builtin_slash_command(action).await,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<u8>>,
|
||||
) -> (
|
||||
tokio::sync::mpsc::UnboundedSender<SubagentEvent>,
|
||||
StdArc<AtomicUsize>,
|
||||
) {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<SubagentEvent>();
|
||||
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<SubagentEvent>,
|
||||
) -> (
|
||||
SessionActor,
|
||||
TempDir,
|
||||
tokio::sync::mpsc::UnboundedReceiver<PersistenceMsg>,
|
||||
) {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let (gateway_tx, _gateway_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<kigi_acp_lib::AcpClientMessage>();
|
||||
let (persistence_tx, persistence_rx) = tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
|
||||
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<PersistenceMsg>,
|
||||
) -> Vec<Option<crate::session::graph_tracker::GraphOrchestration>> {
|
||||
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 <objective> — 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<crate::session::graph_tracker::GraphNode> =
|
||||
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) };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
+12
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
+4
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "<slug>", "title": "...", "spec": "...",
|
||||
//! "deps": ["<slug>", ...]}]}`. 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-<fnv1a32 hex>` 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<PlannedNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct PlannedNode {
|
||||
id: String,
|
||||
title: String,
|
||||
spec: String,
|
||||
#[serde(default)]
|
||||
deps: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>),
|
||||
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<Vec<GraphNode>, 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<String, &str> = 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<GraphNode> = 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<Vec<usize>, 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<String> = (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<serde_json::Value> = 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<String> = (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"));
|
||||
}
|
||||
}
|
||||
@@ -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<GraphNode>),
|
||||
/// 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<dyn GoalPlannerSpawner>,
|
||||
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::<String>(),
|
||||
"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<Vec<u8>>,
|
||||
target: PathBuf,
|
||||
last_prompt: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GoalPlannerSpawner for MockSpawner {
|
||||
async fn spawn_planner(
|
||||
&self,
|
||||
_id: &str,
|
||||
prompt: RoleRenderedPrompt,
|
||||
) -> Result<String, SpawnError> {
|
||||
*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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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-<fnv hex>`); 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<NodeDep>,
|
||||
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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl GraphHistoryEntry {
|
||||
pub(crate) fn now(event: GraphEvent, node_id: Option<String>, detail: Option<String>) -> 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<GraphNode>,
|
||||
/// Node currently running as the active goal, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_node: Option<String>,
|
||||
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<i64>,
|
||||
/// Tokens consumed by completed node goals (boundary-accumulated).
|
||||
#[serde(default)]
|
||||
pub tokens_spent_nodes: i64,
|
||||
pub history: Vec<GraphHistoryEntry>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
fn default_plan_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
// GraphTracker
|
||||
|
||||
/// Pure graph-mode state machine owned by the `SessionActor`.
|
||||
pub struct GraphTracker {
|
||||
session_dir: PathBuf,
|
||||
state: Option<GraphOrchestration>,
|
||||
/// Wall-clock anchor for `account_elapsed`; `Some` only while the
|
||||
/// graph is `Active`. Never persisted.
|
||||
last_probe: Option<Instant>,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/// `<session_dir>/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 (`<session_dir>/graph/<graph_id>`), 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`
|
||||
/// (`<artifacts_dir>/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 (`<artifacts_dir>/<node_id>`).
|
||||
/// 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<GoalStatus> {
|
||||
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<i64> {
|
||||
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<i64>,
|
||||
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<GraphNode>) {
|
||||
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<String>) -> 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<String> = 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<String> = 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<GraphNode>) -> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<crate::session::graph_tracker::GraphOrchestration>),
|
||||
/// 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<crate::session::announcement_state::AnnouncementState>,
|
||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
/// Persisted graph mode orchestration state (None for sessions without graph mode)
|
||||
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
}
|
||||
|
||||
/// 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::<PersistenceMsg>();
|
||||
|
||||
@@ -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("<objective> [--budget <tokens>] | 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 <tokens>` 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<i64>,
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -102,6 +102,9 @@ impl JsonlStorageAdapter {
|
||||
fn goal_mode_state_file(&self, info: &Info) -> PathBuf {
|
||||
self.session_dir(info).join("goal").join("state.json")
|
||||
}
|
||||
fn graph_mode_state_file(&self, info: &Info) -> PathBuf {
|
||||
self.session_dir(info).join("graph").join("state.json")
|
||||
}
|
||||
fn rewind_points_file(&self, info: &Info) -> PathBuf {
|
||||
self.session_dir(info).join("rewind_points.jsonl")
|
||||
}
|
||||
@@ -1095,6 +1098,29 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
}
|
||||
async fn write_graph_mode_state(
|
||||
&self,
|
||||
info: &Info,
|
||||
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
|
||||
) -> io::Result<()> {
|
||||
let target = self.graph_mode_state_file(info);
|
||||
let Some(state) = state else {
|
||||
// Tombstone: a cleared graph must not resurrect on restore.
|
||||
return match tokio::fs::remove_file(&target).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
};
|
||||
let json = serde_json::to_vec_pretty(state)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
if let Some(parent) = target.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
}
|
||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
|
||||
let summary = self.read_summary_sync(info)?;
|
||||
let chat_history =
|
||||
@@ -1116,6 +1142,10 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
|
||||
&self.goal_mode_state_file(info),
|
||||
)?;
|
||||
let graph_mode_state = self
|
||||
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
|
||||
&self.graph_mode_state_file(info),
|
||||
)?;
|
||||
let rewind_points = self.read_jsonl::<RewindPoint>(self.rewind_points_file(info))?;
|
||||
let result = PersistedData {
|
||||
summary,
|
||||
@@ -1127,6 +1157,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
signals,
|
||||
announcement_state,
|
||||
goal_mode_state,
|
||||
graph_mode_state,
|
||||
};
|
||||
tracing::info!(
|
||||
session_id = % info.id, num_chat_messages = result.chat_history.len(),
|
||||
@@ -1164,6 +1195,10 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
.read_optional_json_sync::<crate::session::goal_tracker::GoalOrchestration>(
|
||||
&self.goal_mode_state_file(info),
|
||||
)?;
|
||||
let graph_mode_state = self
|
||||
.read_optional_json_sync::<crate::session::graph_tracker::GraphOrchestration>(
|
||||
&self.graph_mode_state_file(info),
|
||||
)?;
|
||||
let result = super::PersistedDataLight {
|
||||
summary,
|
||||
chat_history,
|
||||
@@ -1172,6 +1207,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
signals,
|
||||
announcement_state,
|
||||
goal_mode_state,
|
||||
graph_mode_state,
|
||||
};
|
||||
tracing::info!(
|
||||
session_id = % info.id, num_chat_messages = result.chat_history.len(),
|
||||
|
||||
@@ -2751,3 +2751,45 @@ async fn load_session_without_updates_survives_merged_chat_line() {
|
||||
"resume succeeds; only the merged record is dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn graph_mode_state_round_trips_and_tombstones() {
|
||||
use crate::session::goal_tracker::{GoalPhase, GoalStatus};
|
||||
use crate::session::graph_tracker::{GraphNode, GraphOrchestration, NodeStatus};
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let adapter = JsonlStorageAdapter::with_root(tmp.path().to_path_buf());
|
||||
let info = create_test_info();
|
||||
adapter.init_session(&info, default_model_id()).await.unwrap();
|
||||
let state = GraphOrchestration {
|
||||
graph_id: "g-1".into(),
|
||||
objective: "obj".into(),
|
||||
status: GoalStatus::Active,
|
||||
phase: GoalPhase::Executing,
|
||||
plan_version: 1,
|
||||
nodes: vec![GraphNode {
|
||||
id: "gn-1".into(), title: "T".into(), spec: "S".into(), deps: vec![],
|
||||
status: NodeStatus::Achieved, goal_id: Some("goal-1".into()),
|
||||
rounds: 2, tokens_used: 42, failure: None,
|
||||
}],
|
||||
current_node: None,
|
||||
created_at: "2026-07-20T00:00:00Z".into(),
|
||||
elapsed_ms: 5,
|
||||
token_budget: Some(100),
|
||||
tokens_spent_nodes: 42,
|
||||
history: vec![],
|
||||
pause_message: None,
|
||||
};
|
||||
adapter.write_graph_mode_state(&info, Some(&state)).await.unwrap();
|
||||
let loaded = adapter.load_session_without_updates(&info).await.unwrap();
|
||||
let got = loaded.graph_mode_state.expect("graph state must round-trip");
|
||||
assert_eq!(got.graph_id, "g-1");
|
||||
assert_eq!(got.nodes.len(), 1);
|
||||
assert_eq!(got.nodes[0].status, NodeStatus::Achieved);
|
||||
assert_eq!(got.nodes[0].tokens_used, 42);
|
||||
assert_eq!(got.token_budget, Some(100));
|
||||
// Tombstone removes the file; a second tombstone is not an error.
|
||||
adapter.write_graph_mode_state(&info, None).await.unwrap();
|
||||
adapter.write_graph_mode_state(&info, None).await.unwrap();
|
||||
let after = adapter.load_session_without_updates(&info).await.unwrap();
|
||||
assert!(after.graph_mode_state.is_none(), "cleared graph must not resurrect");
|
||||
}
|
||||
|
||||
@@ -271,6 +271,8 @@ pub struct PersistedData {
|
||||
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
/// Persisted graph mode orchestration state (None for sessions without graph mode)
|
||||
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
}
|
||||
|
||||
/// Persisted data WITHOUT updates - for memory-efficient session loading
|
||||
@@ -288,6 +290,8 @@ pub struct PersistedDataLight {
|
||||
pub announcement_state: Option<crate::session::announcement_state::AnnouncementState>,
|
||||
/// Persisted goal mode orchestration state (None for sessions without goal mode)
|
||||
pub goal_mode_state: Option<crate::session::goal_tracker::GoalOrchestration>,
|
||||
/// Persisted graph mode orchestration state (None for sessions without graph mode)
|
||||
pub graph_mode_state: Option<crate::session::graph_tracker::GraphOrchestration>,
|
||||
}
|
||||
|
||||
/// Result of copying session data
|
||||
@@ -595,6 +599,14 @@ pub trait StorageAdapter: Send + Sync {
|
||||
state: &crate::session::goal_tracker::GoalOrchestration,
|
||||
) -> io::Result<()>;
|
||||
|
||||
/// Write/update the graph mode orchestration state. `None` removes
|
||||
/// the state file (tombstone after `/graph clear`).
|
||||
async fn write_graph_mode_state(
|
||||
&self,
|
||||
info: &Info,
|
||||
state: Option<&crate::session::graph_tracker::GraphOrchestration>,
|
||||
) -> io::Result<()>;
|
||||
|
||||
/// Load all persisted data for a session
|
||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData>;
|
||||
|
||||
|
||||
@@ -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
|
||||
(`<background_context>`), 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.
|
||||
Reference in New Issue
Block a user