areev_run_core/state.rs
1//! `SchedulerState` — everything a checkpoint carries (§5.4). Rebuilt-from
2//! on resume with nothing inferred from wall-clock or environment; every
3//! collection is a `Vec` or `BTreeMap` so iteration order is content order,
4//! never hash order (§4's determinism rule).
5
6use crate::types::{Ask, DecisionRecord, JournalKey, RunOutcome};
7use serde_json::Value;
8use std::collections::{BTreeMap, BTreeSet};
9
10/// Per-node lifecycle at the node's current generation (§6.2's machine,
11/// operationally realized with per-node re-entry generations).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13pub enum NodeState {
14 /// In-edges unresolved at the current generation.
15 Waiting,
16 /// Dispatchable: no in-edge Pending, ≥1 Fired.
17 Ready,
18 /// An effect is outstanding (Host dispatch or retry in flight).
19 Dispatched,
20 /// Parked on a Client ask (§6.6).
21 AwaitingClient,
22 DoneOk,
23 DoneFailed,
24 /// All in-edges Dead at the current generation.
25 Dead,
26}
27
28/// Resolution of one in-edge at the destination's current generation.
29/// Absent from the map = Pending.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub enum EdgeRes {
32 Fired,
33 Dead,
34}
35
36/// Budget accumulators — pure functions of the journal, carried here as the
37/// running total and re-derived + asserted on replay (§6.7). USD in integer
38/// micro-dollars: budgets never do float arithmetic.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
40pub struct Spent {
41 pub supersteps: u64,
42 pub input_tokens: u64,
43 pub output_tokens: u64,
44 pub usd_micros: u64,
45 /// Active wall only — Σ(superstep close − open). Parked and CRASHED gaps
46 /// accumulate in `elapsed_ms`, reported but never charged (§6.7). The
47 /// crashed half of that promise needs `EventIn::Resumed`: without the
48 /// marker a resume's gap is invisible to the scheduler, and a replay
49 /// (which opens the next superstep at the previous close) charges it as
50 /// wall while the live driver did not.
51 pub wall_ms: u64,
52 pub storage_bytes: u64,
53 pub journal_grains: u64,
54 /// Host tool calls dispatched across the whole run (#295).
55 ///
56 /// `skip_serializing_if` zero, and incremented ONLY when the manifest
57 /// sets the cap: `Spent` is serialized whole into every checkpoint and
58 /// `verify` byte-compares checkpoints, so an unconditional new counter
59 /// would make every pre-existing run diverge with RUN-E009.
60 ///
61 /// The effects axis needs no new state at all — `journal_grains / 2` is
62 /// already the settled-effect count (an intent grain and a result grain
63 /// per effect), with overshoot bounded by one wave.
64 #[serde(default, skip_serializing_if = "is_zero_u64")]
65 pub tool_calls: u64,
66}
67
68fn is_zero_u64(v: &u64) -> bool {
69 *v == 0
70}
71
72/// The run's phase within the BSP cycle.
73#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
74pub enum Phase {
75 /// Between supersteps.
76 Idle,
77 /// A superstep is open: effects outstanding, results buffering, the
78 /// decision record accumulating.
79 Open {
80 /// Keys still awaiting resolution (Host effects and Client asks).
81 outstanding: BTreeSet<JournalKey>,
82 /// Buffered results by node index — merged in canonical order at
83 /// close, never in arrival order (§5.2).
84 results: BTreeMap<usize, Value>,
85 /// Buffered Send-task results by `task_path` (zero-padded ordinals,
86 /// so lexicographic order IS spawn order) — merged after the static
87 /// results, in path order.
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
89 send_results: BTreeMap<String, Value>,
90 record: DecisionRecord,
91 },
92 /// Terminal.
93 Finished(RunOutcome),
94}
95
96/// One Send-spawned task (§5.1: `task_path = parent_path/spawn_ordinal`).
97/// Task-scoped input; attempts continue across re-spawns of the same path
98/// (a later generation re-activating a path keeps the journal keys unique).
99#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
100pub struct SendTask {
101 pub node_idx: usize,
102 pub input: Value,
103 pub attempt: u32,
104 pub state: SendTaskState,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
108pub enum SendTaskState {
109 Queued,
110 Running,
111 Done,
112}
113
114/// One parked Client ask.
115#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
116pub struct PendingAsk {
117 pub key: JournalKey,
118 pub node_idx: usize,
119 pub ask: Ask,
120}
121
122/// A tool call the model made, awaiting its result inside an abstract
123/// node's LLM loop.
124#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
125pub struct PendingToolCall {
126 /// The MODEL's call id (what the transcript's tool_result addresses).
127 pub model_call_id: String,
128 pub tool_name: String,
129 pub arguments: Value,
130}
131
132/// What an abstract flow needs next — set by effect resolution, consumed by
133/// the progress loop (resolution has no command sink; emission is the
134/// loop's job, in canonical order).
135#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
136pub enum FlowNeed {
137 /// Dispatch the next LLM turn over the current transcript.
138 NextTurn,
139 /// Dispatch these model-issued tool calls (already appended to the
140 /// transcript as the assistant entry).
141 Tools(Vec<PendingToolCall>),
142}
143
144/// A summarizer turn in flight: which transcript entries its result will
145/// stand in for, and the `effect_seq` it was dispatched under.
146///
147/// Held in state rather than re-derived, because the decision of WHAT to fold
148/// is made before the model answers and must not be re-made afterwards: the
149/// transcript has not changed in between, but re-deriving would couple the
150/// splice to whatever `fold_range` returns at resolution time rather than to
151/// what the journaled request actually summarized.
152#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
153pub struct FoldInFlight {
154 /// First transcript index covered (inclusive).
155 pub from: usize,
156 /// One past the last covered index.
157 pub to: usize,
158 /// The summarizer turn's own effect ordinal.
159 pub effect_seq: u32,
160}
161
162/// A decision effect in flight inside an abstract flow (C1/C2): what it was
163/// asked FOR, and the `effect_seq` it was dispatched under.
164///
165/// Held in state for the [`FoldInFlight`] reason: the window a fold decision
166/// covers is fixed when it is asked and must not be re-derived when it is
167/// answered.
168#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
169pub enum DecideInFlight {
170 /// The decision-guided fold (C2) over transcript entries `from..to`.
171 Fold { from: usize, to: usize, effect_seq: u32 },
172 /// The tool-offer narrowing (C1), asked before the flow's first turn.
173 Offer { effect_seq: u32 },
174}
175
176impl DecideInFlight {
177 pub fn effect_seq(&self) -> u32 {
178 match self {
179 DecideInFlight::Fold { effect_seq, .. } | DecideInFlight::Offer { effect_seq } => {
180 *effect_seq
181 }
182 }
183 }
184}
185
186/// In-flight LLM-loop state for one abstract node at its current attempt
187/// (§6.2's "a workflow with abstract nodes is an agent — same runtime").
188/// Every entry is built from journaled outcomes, so the transcript is
189/// replay-stable by construction.
190#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
191pub struct AbstractFlow {
192 /// Journal-shaped messages:
193 /// `{"role":"user","content":…}` ·
194 /// `{"role":"assistant","text":…,"tool_calls":[{id,name,arguments}]}` ·
195 /// `{"role":"tool","tool_call_id":…,"content":…,"is_error":…}`.
196 pub messages: Vec<Value>,
197 /// The next effect ordinal within this attempt (LLM turns and tool
198 /// calls share the sequence — §5.1).
199 pub next_effect_seq: u32,
200 /// The current tool round: effect_seq → the model call it serves.
201 pub pending_tools: BTreeMap<u32, PendingToolCall>,
202 /// Settled results of the current round, by effect_seq (canonical
203 /// transcript order regardless of completion order).
204 pub round_results: BTreeMap<u32, Value>,
205 /// What the loop should do next (None while waiting on effects).
206 pub need: Option<FlowNeed>,
207 /// Unknown-tool corrections issued (§6.11: ONE re-prompt, then the
208 /// node fails `ExecutorError`).
209 pub unknown_strikes: u32,
210 /// The provider's OWN reported prompt-token count for the last completed
211 /// turn — the transcript's size at the model's tokenizer, not an estimate.
212 /// 0 before the first turn settles, and reset to 0 by a fold so the next
213 /// real turn measures the spliced transcript rather than the old one.
214 ///
215 /// Every one of the fold fields carries `skip_serializing_if` at its
216 /// default: an `AbstractFlow` in a run that configured no ceiling must
217 /// serialize to exactly the bytes it did before these existed, or every
218 /// stored checkpoint diverges on the next `verify`.
219 #[serde(default, skip_serializing_if = "is_zero")]
220 pub last_prompt_tokens: u64,
221 /// Set while a summarizer turn is outstanding; taken at its resolution.
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub folding: Option<FoldInFlight>,
224 /// The provider rejected the last turn as too long: fold before trying
225 /// again, whatever the configured ceiling says.
226 ///
227 /// Separate from the ceiling because it answers a different question. The
228 /// ceiling is a PREDICTION made from the previous turn's token count, and
229 /// it can be unset, or set too high, or right but measuring a transcript
230 /// that has since grown. This is the provider's own verdict on the
231 /// transcript as actually sent, and it has to win over all three.
232 #[serde(default, skip_serializing_if = "is_false")]
233 pub fold_forced: bool,
234 /// Folds performed on this attempt, and the number in the fold message the
235 /// model reads. A completed node's flow is gone by the terminal checkpoint,
236 /// so the durable record of a fold is its journaled intent (`input.fold`,
237 /// visible in `run-trace`) rather than this counter.
238 #[serde(default, skip_serializing_if = "is_zero_u32")]
239 pub folds: u32,
240 /// A decision effect outstanding for this flow (C1/C2); taken at its
241 /// resolution. Like every field below, absent unless a decision backend
242 /// is pinned in the manifest, so existing checkpoints are byte-identical.
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub deciding: Option<DecideInFlight>,
245 /// A decision-guided fold (C2) has already been tried since the
246 /// transcript was last measured UNDER its ceiling. While set, the next
247 /// fold trigger goes straight to the summarizer — the "still too long
248 /// afterwards" fall-through, and the guard that stops a flow asking the
249 /// same window twice.
250 #[serde(default, skip_serializing_if = "is_false")]
251 pub decide_fold_tried: bool,
252 /// The narrowed tool offer (C1) and its provenance —
253 /// `{tools, seq, provider, model, calibrated, latency_ms}` — ridden into
254 /// every turn's journaled input as `offer`, which is what the driver
255 /// filters the offered Definitions by. `None` = the full pinned set.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub offer: Option<Value>,
258 /// Corrective re-prompts issued for a final answer that failed the node's
259 /// `response` schema (#391): ONE, then the node fails
260 /// `SchemaValidationFailed`. Zero — and absent — on every flow whose node
261 /// asks for no structured output.
262 #[serde(default, skip_serializing_if = "is_zero_u32")]
263 pub response_strikes: u32,
264 /// Every settled tool call of this attempt, in `effect_seq` order (never
265 /// arrival order — results of one round land in any order), for a
266 /// node whose `llm` block names `tool_results_into` (#391):
267 /// `{name, tool_call_id, model_call_id, input, result, is_error,
268 /// effect_seq}`. Built from the journaled outcomes as they resolve, so
269 /// `verify` rebuilds it byte for byte. Empty — and absent — otherwise.
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
271 pub tool_log: Vec<Value>,
272}
273
274fn is_zero(n: &u64) -> bool {
275 *n == 0
276}
277
278fn is_zero_u32(n: &u32) -> bool {
279 *n == 0
280}
281
282fn is_false(b: &bool) -> bool {
283 !*b
284}
285
286/// The complete scheduler state. Serialized (as JSON) into each checkpoint's
287/// `context_data`; byte-stability across resumes is what the
288/// replay-equivalence gate compares.
289#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
290pub struct SchedulerState {
291 pub run_id: String,
292 pub superstep: u64,
293 pub phase: Phase,
294 /// Per node: re-entry generation (0 = first activation).
295 pub node_gen: Vec<u32>,
296 pub node_state: Vec<NodeState>,
297 /// Per node: in-edge resolutions AT ITS CURRENT GENERATION, keyed by
298 /// edge index. Absent = Pending.
299 pub in_res: Vec<BTreeMap<usize, EdgeRes>>,
300 /// Per edge: total Fired count across the run (`max_cycles` counts
301 /// these — §6.3: "fires in at most n generations per run").
302 pub edge_fired: Vec<u32>,
303 /// Per node: attempt counter — MONOTONIC across re-entry generations
304 /// (never reset), because the journal key is
305 /// (run, task_path, node, attempt, effect_seq, kind) and a reset would
306 /// mint generation 1 the same keys as generation 0 — the driver would
307 /// then answer iteration 2 from iteration 1's journaled results and the
308 /// loop body would execute once, ever. The per-generation retry budget
309 /// measures from `attempt_base` instead.
310 pub attempt: Vec<u32>,
311 /// Per node: the attempt count when the CURRENT generation began —
312 /// `attempt - attempt_base` is this generation's attempt number.
313 pub attempt_base: Vec<u32>,
314 /// The accumulated run state the reducers fold into.
315 pub context: Value,
316 pub spent: Spent,
317 /// Un-charged calendar time (parks, crashes) — reported, never billed.
318 pub elapsed_ms: u64,
319 /// The latest journaled clock reading seen.
320 pub clock_ms: u64,
321 /// The active wall segment's start (None while parked/idle). Wall
322 /// charges only active segments — open→park and resume→close — so a
323 /// three-day approval pause costs nothing (§6.7).
324 pub wall_open: Option<u64>,
325 /// When the current park began (drives `elapsed_ms` on resume).
326 pub paused_at: Option<u64>,
327 /// Parked Client asks by `tool_call_id` (§6.6 — addressed by id, never
328 /// by index).
329 pub pending_asks: BTreeMap<String, PendingAsk>,
330 /// In-flight abstract-node LLM loops, by node index.
331 pub abstract_flows: BTreeMap<String, AbstractFlow>,
332 /// Send-spawned tasks by `task_path`. A target node shows `Dispatched`
333 /// while its batch is unresolved and completes when the batch drains.
334 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
335 pub send_tasks: BTreeMap<String, SendTask>,
336 /// Next spawn ordinal per parent path — monotonic for the whole run, so
337 /// a re-entered spawner mints fresh task paths and journal keys never
338 /// collide across generations.
339 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
340 pub spawn_counter: BTreeMap<String, u32>,
341 /// Ask ids already announced in an envelope — a parked run polled again
342 /// must not re-emit its envelope every step call.
343 pub announced_asks: BTreeSet<String>,
344 /// Steering messages queued since the last superstep opened, INERT
345 /// until one does: an open moves them into `context` under `$inbox`.
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
347 pub inbox: Vec<Value>,
348 /// How many steering messages the run has ever accepted.
349 #[serde(default, skip_serializing_if = "is_zero")]
350 pub inputs_seen: u64,
351 /// A cancel marker observed (drain in progress).
352 pub cancel: Option<(String, String)>,
353 /// A budget axis exhausted mid-superstep (per-dispatch reservation,
354 /// §6.7): no new work is emitted; the open superstep's effects drain,
355 /// then the run finishes `BudgetExhausted` — checkpointed, resumable
356 /// after a manifest supersession raises the ceiling.
357 pub exhausted: Option<crate::error::BudgetAxis>,
358 /// Set when fail-fast drain began: the lowest-index permanently-failed
359 /// node and its detail (deterministic under permutation — chosen at
360 /// superstep close, not at arrival).
361 pub failed: Option<(usize, String)>,
362 /// Transcript folds performed across the WHOLE run.
363 ///
364 /// Per-flow `AbstractFlow.folds` disappears with the flow when its node
365 /// completes, so by the terminal checkpoint there is nothing left to count
366 /// — and "did this workflow keep outgrowing the model's window?" is a
367 /// question about the run, asked after it ended. Carried here so the
368 /// run-outcome record can state it and the loop can act on it.
369 #[serde(default, skip_serializing_if = "is_zero_u32")]
370 pub folds: u32,
371}
372
373impl SchedulerState {
374 /// Fresh state for a validated plan.
375 pub fn new(run_id: &str, plan: &crate::plan::PlanGraph) -> Self {
376 let n = plan.nodes.len();
377 SchedulerState {
378 run_id: run_id.to_string(),
379 superstep: 0,
380 phase: Phase::Idle,
381 node_gen: vec![0; n],
382 node_state: vec![NodeState::Waiting; n],
383 in_res: vec![BTreeMap::new(); n],
384 edge_fired: vec![0; plan.edges.len()],
385 attempt: vec![0; n],
386 attempt_base: vec![0; n],
387 context: Value::Object(serde_json::Map::new()),
388 spent: Spent::default(),
389 elapsed_ms: 0,
390 clock_ms: 0,
391 wall_open: None,
392 paused_at: None,
393 pending_asks: BTreeMap::new(),
394 abstract_flows: BTreeMap::new(),
395 send_tasks: BTreeMap::new(),
396 spawn_counter: BTreeMap::new(),
397 announced_asks: BTreeSet::new(),
398 inbox: Vec::new(),
399 inputs_seen: 0,
400 cancel: None,
401 exhausted: None,
402 failed: None,
403 folds: 0,
404 }
405 }
406
407 pub fn is_terminal(&self) -> bool {
408 matches!(self.phase, Phase::Finished(_))
409 }
410
411 pub fn outcome(&self) -> Option<&RunOutcome> {
412 match &self.phase {
413 Phase::Finished(o) => Some(o),
414 _ => None,
415 }
416 }
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422
423 fn bare_flow() -> AbstractFlow {
424 AbstractFlow {
425 messages: vec![serde_json::json!({"role": "user", "content": "go"})],
426 next_effect_seq: 1,
427 pending_tools: BTreeMap::new(),
428 round_results: BTreeMap::new(),
429 need: None,
430 unknown_strikes: 0,
431 last_prompt_tokens: 0,
432 folding: None,
433 fold_forced: false,
434 folds: 0,
435 deciding: None,
436 decide_fold_tried: false,
437 offer: None,
438 response_strikes: 0,
439 tool_log: Vec::new(),
440 }
441 }
442
443 /// The replay-equivalence gate compares checkpoint BYTES, so a run that
444 /// configured no context ceiling must serialize its flows exactly as it did
445 /// before the fold existed. Every fold field is therefore `serde(default)`
446 /// plus `skip_serializing_if` at its default — and this is the test that
447 /// notices when the next field forgets one.
448 #[test]
449 fn a_flow_with_no_fold_state_serializes_as_it_did_before_folds_existed() {
450 let json = serde_json::to_string(&bare_flow()).unwrap();
451 assert_eq!(
452 json,
453 r#"{"messages":[{"content":"go","role":"user"}],"next_effect_seq":1,"pending_tools":{},"round_results":{},"need":null,"unknown_strikes":0}"#
454 );
455 }
456
457 /// And the fold state round-trips byte-stably once it IS set — a checkpoint
458 /// taken mid-fold has to resume into the same splice decision.
459 #[test]
460 fn fold_state_round_trips_byte_stably() {
461 let flow = AbstractFlow {
462 last_prompt_tokens: 120_000,
463 folding: Some(FoldInFlight { from: 1, to: 9, effect_seq: 9 }),
464 folds: 2,
465 ..bare_flow()
466 };
467 let bytes = serde_json::to_string(&flow).unwrap();
468 let back: AbstractFlow = serde_json::from_str(&bytes).unwrap();
469 assert_eq!(back, flow);
470 assert_eq!(serde_json::to_string(&back).unwrap(), bytes);
471 }
472}