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:
1 parent
4d1e4fdc52
commit
1579558b56
20 files changed
+878
-57
No files matched your search
@@ -562,8 +562,11 @@ impl GraphTracker {
|
||||
}
|
||||
|
||||
/// `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.
|
||||
/// Every in-flight node (the serial engine node AND any parallel
|
||||
/// batch node) demotes to `Ready`: a budget trip is a resource
|
||||
/// stop, not a verdict on the node — so no forever-`Running` node
|
||||
/// is ever persisted, and a later budget top-up
|
||||
/// ([`Self::resume_budget_limited`]) re-dispatches it naturally.
|
||||
pub fn budget_limit(&mut self) -> bool {
|
||||
self.account_elapsed();
|
||||
let Some(state) = self.state.as_mut() else {
|
||||
@@ -573,12 +576,10 @@ impl GraphTracker {
|
||||
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());
|
||||
for node in &mut state.nodes {
|
||||
if matches!(node.status, NodeStatus::Running | NodeStatus::Verifying) {
|
||||
node.status = NodeStatus::Ready;
|
||||
}
|
||||
}
|
||||
state.current_node = None;
|
||||
state.status = GoalStatus::BudgetLimited;
|
||||
@@ -591,6 +592,31 @@ impl GraphTracker {
|
||||
true
|
||||
}
|
||||
|
||||
/// `BudgetLimited -> Active` with `extra` fresh headroom: the new
|
||||
/// budget becomes spent-so-far + extra. `true` if applied.
|
||||
pub fn resume_budget_limited(&mut self, extra: i64) -> bool {
|
||||
let Some(state) = self.state.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if state.status != GoalStatus::BudgetLimited || extra <= 0 {
|
||||
return false;
|
||||
}
|
||||
state.token_budget = Some(state.tokens_spent_nodes.saturating_add(extra));
|
||||
state.status = GoalStatus::Active;
|
||||
state.pause_message = None;
|
||||
push_history(
|
||||
state,
|
||||
GraphHistoryEntry::now(
|
||||
GraphEvent::GraphResumed,
|
||||
None,
|
||||
Some(format!("budget topped up by {extra}")),
|
||||
),
|
||||
);
|
||||
self.last_probe = Some(Instant::now());
|
||||
self.recompute_ready();
|
||||
true
|
||||
}
|
||||
|
||||
/// Drop all graph state (history records the clear first so a
|
||||
/// final persisted snapshot, if any, carries it).
|
||||
pub fn clear(&mut self) {
|
||||
@@ -860,27 +886,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_limit_terminally_fails_the_running_node() {
|
||||
fn budget_limit_demotes_in_flight_and_top_up_resumes() {
|
||||
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());
|
||||
t.charge_node_tokens("a", 12);
|
||||
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")
|
||||
NodeStatus::Ready,
|
||||
"budget trip is a resource stop, not a node verdict — no \
|
||||
forever-Running node, runnable again after a top-up"
|
||||
);
|
||||
assert_eq!(s.current_node, None);
|
||||
|
||||
assert!(!t.resume_budget_limited(0), "top-up must be positive");
|
||||
assert!(t.resume_budget_limited(100));
|
||||
assert_eq!(t.status(), Some(GoalStatus::Active));
|
||||
assert_eq!(
|
||||
t.snapshot().unwrap().token_budget,
|
||||
Some(112),
|
||||
"new budget = spent so far + extra headroom"
|
||||
);
|
||||
assert_eq!(t.remaining_budget(), Some(100));
|
||||
assert_eq!(t.next_ready_node().unwrap().id, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user