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:
@@ -897,6 +897,36 @@ pub enum SessionUpdate {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
usage: Option<PromptUsage>,
|
||||
},
|
||||
/// Graph mode (`/graph`) progress for the pager's status chip.
|
||||
/// Wire tag `graph_updated`; `status: "cleared"` tells the pager to
|
||||
/// drop its graph state (same sentinel convention as `GoalUpdated`).
|
||||
/// Old pagers degrade to [`Self::Unknown`] silently.
|
||||
GraphUpdated {
|
||||
graph_id: String,
|
||||
objective: String,
|
||||
/// Goal-status vocabulary (`active`, paused family,
|
||||
/// `budget_limited`, `complete`) plus `cleared`.
|
||||
status: String,
|
||||
/// `idle` | `planning` | `executing`.
|
||||
phase: String,
|
||||
plan_version: u32,
|
||||
total_nodes: u32,
|
||||
achieved_nodes: u32,
|
||||
failed_nodes: u32,
|
||||
running_nodes: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_node: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
current_node_title: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
token_budget: Option<i64>,
|
||||
#[serde(default)]
|
||||
tokens_spent: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
last_event: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pause_message: Option<String>,
|
||||
},
|
||||
/// Catch-all for unrecognized session update types.
|
||||
/// Allows forward/backward compatibility when variants are added or removed.
|
||||
/// All fields from the unrecognized variant are discarded during deserialization.
|
||||
@@ -2293,3 +2323,88 @@ mod tests {
|
||||
assert!(serde_json::from_str::<SessionUpdate>(missing_stop_reason).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod graph_updated_wire_tests {
|
||||
use super::*;
|
||||
|
||||
fn full_graph_updated() -> SessionUpdate {
|
||||
SessionUpdate::GraphUpdated {
|
||||
graph_id: "g-1".into(),
|
||||
objective: "ship it".into(),
|
||||
status: "active".into(),
|
||||
phase: "executing".into(),
|
||||
plan_version: 2,
|
||||
total_nodes: 5,
|
||||
achieved_nodes: 2,
|
||||
failed_nodes: 1,
|
||||
running_nodes: 1,
|
||||
current_node: Some("gn-abc".into()),
|
||||
current_node_title: Some("Node C".into()),
|
||||
token_budget: Some(10_000),
|
||||
tokens_spent: 4_200,
|
||||
last_event: Some("node_achieved".into()),
|
||||
pause_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_updated_round_trips_with_snake_case_tag() {
|
||||
let update = full_graph_updated();
|
||||
let json = serde_json::to_value(&update).unwrap();
|
||||
assert_eq!(json["sessionUpdate"], "graph_updated");
|
||||
assert_eq!(json["achieved_nodes"], 2);
|
||||
assert_eq!(json["current_node_title"], "Node C");
|
||||
// Omitted optionals must not serialize at all.
|
||||
assert!(json.get("pause_message").is_none());
|
||||
let back: SessionUpdate = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_updated_minimal_payload_fills_defaults() {
|
||||
// Only the required fields on the wire: every optional absent,
|
||||
// `tokens_spent` relies on #[serde(default)].
|
||||
let json = serde_json::json!({
|
||||
"sessionUpdate": "graph_updated",
|
||||
"graph_id": "g-2",
|
||||
"objective": "o",
|
||||
"status": "cleared",
|
||||
"phase": "idle",
|
||||
"plan_version": 0,
|
||||
"total_nodes": 0,
|
||||
"achieved_nodes": 0,
|
||||
"failed_nodes": 0,
|
||||
"running_nodes": 0,
|
||||
});
|
||||
let update: SessionUpdate = serde_json::from_value(json).unwrap();
|
||||
match update {
|
||||
SessionUpdate::GraphUpdated {
|
||||
status,
|
||||
tokens_spent,
|
||||
current_node,
|
||||
token_budget,
|
||||
pause_message,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(status, "cleared");
|
||||
assert_eq!(tokens_spent, 0, "#[serde(default)] must backfill");
|
||||
assert!(current_node.is_none());
|
||||
assert!(token_budget.is_none());
|
||||
assert!(pause_message.is_none());
|
||||
}
|
||||
other => panic!("expected GraphUpdated, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An OLD pager (this enum before the variant existed) must degrade
|
||||
/// a graph_updated payload to `Unknown` — pinned by feeding an
|
||||
/// unknown-tag payload through today's enum, which uses the same
|
||||
/// #[serde(other)] mechanism.
|
||||
#[test]
|
||||
fn unknown_tags_still_degrade_gracefully() {
|
||||
let json = serde_json::json!({ "sessionUpdate": "graph_updated_v99", "x": 1 });
|
||||
let update: SessionUpdate = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(update, SessionUpdate::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user