Add /graph G2: resumable budget, GraphUpdated status chip, PTY + turn-level coverage

BudgetLimited is now a resumable state: a budget trip demotes in-flight
nodes to Ready (a resource stop, not a verdict — no forever-Running node is
ever persisted) and '/graph resume --budget <tokens>' re-arms the graph with
fresh headroom (new budget = spent-so-far + extra). The tripped node's
partial burn is charged into tokens_spent_nodes at BOTH cascade sites before
the demotion clears current_node, so the top-up arithmetic never runs on an
under-counted ledger. Any input starting with 'resume' resolves to a resume
(case-insensitive; malformed top-ups surface the usage hint) and setup_graph
refuses to replace any non-Complete graph — a typo can no longer silently
destroy a resumable graph. An explicit --budget on a merely-paused graph is
rejected loudly instead of silently discarded; all trip-time messages now
advertise the top-up.

The pager gains a graph status chip: a new GraphUpdated wire variant
(extensions/notification.rs, old pagers degrade via #[serde(other)]) is
emitted from the single persist_graph_state chokepoint — every transition is
both a checkpoint and a badge tick — with a 'cleared' sentinel on /graph
clear and a one-shot re-emit after session restore (the replayed updates log
otherwise shows the pre-shutdown Active state that from_snapshot just
demoted in memory). TUI side: GraphDisplayState, session-notification arm,
and a goal-idiom chip with node progress, clamped current-node title, and
budget-aware spend. Pre-session command availability now advertises /graph
from the flags (it was fail-closed to the in-session path only, so the
welcome-screen slash menu never showed it).

Coverage: GraphUpdated wire round-trip + minimal-payload + unknown-tag
tests; PTY scenarios graph_slash_presession{,_disabled}.yaml (both run
green against the real pager binary); handle_prompt-level e2e for terminal
slash outcomes (/graph status|resume|pause, /goal refusals while the graph
owns the engine); budget top-up e2e driving a BudgetLimited diamond back to
Complete. Not shimmed: pre-G2 persisted snapshots with budget-Failed nodes
(the KIGI_GRAPH flag has never shipped enabled, so none exist).

kigi-shell 4927 and kigi-tui 6610 lib tests green; workspace clippy clean.
This commit is contained in:
2026-07-20 16:35:02 -04:00
parent 4d1e4fdc52
commit 1579558b56
20 changed files with 878 additions and 57 deletions
@@ -977,6 +977,37 @@ pub(super) fn handle_session_notification(notif: &acp::ExtNotification, app: &mu
XaiSessionUpdate::InteractionResolved { tool_call_id } => {
agent.dismiss_resolved_interaction(&tool_call_id)
}
XaiSessionUpdate::GraphUpdated {
objective,
status,
total_nodes,
achieved_nodes,
failed_nodes,
running_nodes,
current_node_title,
token_budget,
tokens_spent,
pause_message,
..
} => {
if status == "cleared" {
agent.graph_state.take();
} else {
agent.graph_state = Some(crate::app::agent::GraphDisplayState {
objective,
status: crate::app::agent::GoalDisplayStatus::parse(&status),
total_nodes,
achieved_nodes,
failed_nodes,
running_nodes,
current_node_title,
token_budget,
tokens_spent,
pause_message,
});
}
true
}
_ => {
tracing::trace!(
"Ignoring {}: {:?}",
+19
View File
@@ -385,6 +385,25 @@ impl GoalDisplayPhase {
}
}
}
/// Graph mode display state — the pager-side mirror of the
/// `GraphUpdated` session notification (see the shell's
/// `extensions/notification.rs`). Deliberately lean: the status chip
/// shows counts + the current node; details live in `/graph status`.
#[derive(Debug, Clone)]
pub struct GraphDisplayState {
pub objective: String,
/// Reuses the goal display-status vocabulary (same wire strings).
pub status: GoalDisplayStatus,
pub total_nodes: u32,
pub achieved_nodes: u32,
pub failed_nodes: u32,
pub running_nodes: u32,
pub current_node_title: Option<String>,
pub token_budget: Option<i64>,
pub tokens_spent: i64,
pub pause_message: Option<String>,
}
/// Display state for an active goal, populated from `GoalUpdated`
/// session notifications emitted by the goal orchestrator.
#[derive(Debug, Clone)]
@@ -745,6 +745,9 @@ pub struct AgentView {
/// Current goal orchestration state. Set by `GoalUpdated` session
/// notifications, cleared when a new session starts.
pub goal_state: Option<super::agent::GoalDisplayState>,
/// Current graph orchestration state. Set by `GraphUpdated` session
/// notifications, cleared on the `"cleared"` sentinel / new session.
pub graph_state: Option<super::agent::GraphDisplayState>,
/// The consumed parked-wait marker slot for the current turn, if any.
/// Keyed by prompt id: a new turn naturally invalidates the slot with no
/// explicit clear site. See [`ParkedMarkerSlot`].
@@ -1243,6 +1243,13 @@ impl AgentView {
),
);
}
if let Some(ref graph) = self.graph_state {
let tick = self.tasks.tick_count() as usize;
status.push(
"graph",
crate::views::agent_status::graph_status_line(graph, &theme, tick),
);
}
if let Some(mcp_line) = self.mcp_init_progress.as_ref().and_then(|p| {
crate::views::agent_status::mcp_status_line(p, self.scrollback.animation_tick(), &theme)
}) {
@@ -93,6 +93,7 @@ impl AgentView {
chat_kind: false,
app_chat_mode: false,
goal_state: None,
graph_state: None,
parked_wait_marker_for: None,
end_work_announced: false,
pending_stop_hooks: None,