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
@@ -282,6 +282,75 @@ pub fn goal_status_line(
])
}
// ---------------------------------------------------------------------------
// Graph status chip
// ---------------------------------------------------------------------------
/// Build the compact `/graph` status chip: node progress, the current
/// node, and spend. Same chip idiom as [`goal_status_line`] — dim
/// brackets, paused chips invert onto `theme.warning`, active chips
/// animate.
pub fn graph_status_line(
graph: &crate::app::agent::GraphDisplayState,
theme: &Theme,
tick: usize,
) -> Line<'static> {
let dim_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let label_style = if graph.status.is_paused() {
Style::default().fg(theme.bg_base).bg(theme.warning)
} else {
Style::default().fg(theme.accent_plan).bg(theme.bg_base)
};
let is_active = matches!(graph.status, GoalDisplayStatus::Active);
let mut progress = format!("{}/{}", graph.achieved_nodes, graph.total_nodes);
if graph.failed_nodes > 0 {
progress.push_str(&format!(" ({} failed)", graph.failed_nodes));
}
// Planner titles are uncapped; clamp so one long title can't push
// the whole status bar off-screen.
let clamped_title = graph.current_node_title.as_deref().map(|t| {
if t.chars().count() > 40 {
let head: String = t.chars().take(39).collect();
format!("{head}")
} else {
t.to_owned()
}
});
let label = match (&graph.status, clamped_title.as_deref()) {
(GoalDisplayStatus::Active, Some(title)) => format!("{progress} · {title}"),
(GoalDisplayStatus::Active, None) if graph.running_nodes > 1 => {
format!("{progress} · {} nodes in flight", graph.running_nodes)
}
(GoalDisplayStatus::Complete, _) => format!("{progress} · complete"),
(GoalDisplayStatus::BudgetLimited, _) => format!("{progress} · budget limit"),
(status, _) if status.is_paused() => format!("{progress} · paused"),
_ => progress,
};
let graph_text = if is_active {
let frames = crate::glyphs::dot_spinner_frames();
let frame = frames[(tick / 4) % frames.len()];
format!("{frame} Graph: {label}")
} else {
format!("Graph: {label}")
};
let tokens_str = format_tokens_compact(graph.tokens_spent.max(0));
let tokens_display = match graph.token_budget {
Some(budget) if budget > 0 => {
format!("{}/{} tokens", tokens_str, format_tokens_compact(budget))
}
_ => format!("{tokens_str} tokens"),
};
Line::from(vec![
Span::styled("[", dim_style),
Span::styled(graph_text, label_style),
Span::styled("]", dim_style),
Span::styled(format!(" {tokens_display}"), dim_style),
])
}
// ---------------------------------------------------------------------------
// MCP connecting indicator
// ---------------------------------------------------------------------------