Skip to main content

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}