Skip to main content

lean_ctx_workgraph/work_graph/
control_ops.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use super::path_claims_overlap;
4use super::{
5    BTreeSet, BoundedWorkGraph, ChainBudget, ChildOutcome, MAX_DEPTH, MAX_FAN_OUT, MAX_GRAPH_NODES,
6    MAX_LOCAL_CONCURRENCY, MAX_PATH_CLAIMS, NodeStatus, StopReason, WorkGraphError, WorkNode,
7    WorkNodeBudget, execution_fence, validate_budget, validate_identifier, validate_measured_spend,
8    validate_path_claim, validate_receipt,
9};
10
11impl BoundedWorkGraph {
12    /// Validate every persisted structural and accounting invariant before use.
13    pub fn validate_invariants(&self) -> Result<(), WorkGraphError> {
14        for (node_id, fence) in &self.reaped_executions {
15            if !self.nodes.get(node_id).is_some_and(|node| {
16                node.execution_started && node.execution_fence.as_ref() == Some(fence)
17            }) {
18                return Err(WorkGraphError::InvalidGraph(
19                    "invalid execution exit acknowledgement".into(),
20                ));
21            }
22        }
23        if !self.graph_id.is_empty() {
24            validate_identifier(&self.graph_id, "graph_id")?;
25        }
26        if self.nodes.len() > MAX_GRAPH_NODES
27            || self.max_fan_out == 0
28            || self.max_fan_out > MAX_FAN_OUT
29            || self.max_depth == 0
30            || self.max_depth > MAX_DEPTH
31            || self.max_local_concurrency == 0
32            || self.max_local_concurrency > MAX_LOCAL_CONCURRENCY
33            || self.active_count() > self.local_concurrency_limit()
34        {
35            return Err(WorkGraphError::InvalidGraph(
36                "configured limit exceeded".to_string(),
37            ));
38        }
39        for (node_id, node) in &self.nodes {
40            validate_identifier(node_id, "node_id")?;
41            validate_identifier(&node.node_id, "node_id")?;
42            validate_identifier(&node.agent_id, "agent_id")?;
43            validate_identifier(&node.capsule_ref, "capsule_ref")?;
44            validate_budget(&node.budget)?;
45            for (field, value) in [
46                ("task_ref", node.task_ref.as_str()),
47                ("policy_ref", node.policy_ref.as_str()),
48                ("expected_outcome_ref", node.expected_outcome_ref.as_str()),
49            ] {
50                if !value.is_empty() {
51                    validate_identifier(value, field)?;
52                }
53            }
54            if let Some(parent_task_ref) = node.parent_task_ref.as_deref() {
55                validate_identifier(parent_task_ref, "parent_task_ref")?;
56            }
57            if let Some(parent_id) = node.parent_node_id.as_deref()
58                && !node.task_ref.is_empty()
59            {
60                let parent = self
61                    .nodes
62                    .get(parent_id)
63                    .ok_or_else(|| WorkGraphError::InvalidGraph("missing context parent".into()))?;
64                if node.parent_task_ref.as_deref() != Some(parent.task_ref.as_str())
65                    || node.policy_ref != parent.policy_ref
66                {
67                    return Err(WorkGraphError::InvalidGraph(
68                        "task lineage or policy inheritance mismatch".into(),
69                    ));
70                }
71            }
72            if node.path_claims.len() > MAX_PATH_CLAIMS {
73                return Err(WorkGraphError::TooManyPathClaims);
74            }
75            let mut path_claims = BTreeSet::new();
76            for claim in &node.path_claims {
77                validate_path_claim(claim)?;
78                if !path_claims.insert(claim) {
79                    return Err(WorkGraphError::InvalidGraph(
80                        "duplicate path claim".to_string(),
81                    ));
82                }
83            }
84            if node_id != &node.node_id {
85                return Err(WorkGraphError::InvalidGraph(
86                    "node key mismatch".to_string(),
87                ));
88            }
89            if let Some(fence) = node.execution_fence.as_deref()
90                && fence != execution_fence(&self.graph_id, node)
91            {
92                return Err(WorkGraphError::InvalidGraph(
93                    "invalid execution fence".to_string(),
94                ));
95            }
96            if node
97                .delivery_attempt
98                .is_some_and(|attempt| !(1..=super::MAX_DELIVERY_ATTEMPTS).contains(&attempt))
99                || (node.delivery_attempt.is_some()
100                    && (!node.execution_started || node.execution_fence.is_none()))
101            {
102                return Err(WorkGraphError::InvalidGraph(
103                    "invalid delivery attempt".into(),
104                ));
105            }
106            let lineage = self.checked_lineage(node_id)?;
107            if usize::from(node.depth) + 1 != lineage.len() || node.depth > self.max_depth {
108                return Err(WorkGraphError::InvalidGraph(
109                    "invalid node depth".to_string(),
110                ));
111            }
112            if let Some(parent_id) = node.parent_node_id.as_deref()
113                && !self
114                    .children
115                    .get(parent_id)
116                    .is_some_and(|children| children.contains(node_id))
117            {
118                return Err(WorkGraphError::InvalidGraph(
119                    "parent/child mismatch".to_string(),
120                ));
121            }
122            if node.parent_node_id.is_none() && !self.chain_budgets.contains_key(node_id) {
123                return Err(WorkGraphError::InvalidGraph(
124                    "missing chain budget".to_string(),
125                ));
126            }
127        }
128        for (parent_id, children) in &self.children {
129            if !self.nodes.contains_key(parent_id)
130                || children.len() > self.max_fan_out
131                || children.iter().collect::<BTreeSet<_>>().len() != children.len()
132            {
133                return Err(WorkGraphError::InvalidGraph(
134                    "invalid child index".to_string(),
135                ));
136            }
137            for child_id in children {
138                if self
139                    .nodes
140                    .get(child_id)
141                    .and_then(|node| node.parent_node_id.as_deref())
142                    != Some(parent_id)
143                {
144                    return Err(WorkGraphError::InvalidGraph("dangling child".to_string()));
145                }
146            }
147            let (tokens, cost) = self.reserved_child_budget(parent_id, None)?;
148            let parent = &self.nodes[parent_id];
149            if tokens > parent.budget.tokens_remaining() || cost > parent.budget.cost_remaining() {
150                return Err(WorkGraphError::InvalidGraph(
151                    "child budget over-allocation".to_string(),
152                ));
153            }
154        }
155        for (child_id, budget) in &self.pending_child_budgets {
156            validate_identifier(child_id, "pending_child_id")?;
157            validate_budget(budget)?;
158            if self.nodes.contains_key(child_id)
159                || !self.pending_child_parents.contains_key(child_id)
160            {
161                return Err(WorkGraphError::InvalidGraph(
162                    "invalid pending budget".to_string(),
163                ));
164            }
165        }
166        for receipt in self.receipts.values() {
167            validate_receipt(receipt)?;
168            let node = self
169                .nodes
170                .get(&receipt.node_id)
171                .ok_or_else(|| WorkGraphError::InvalidGraph("orphan receipt".to_string()))?;
172            if node.parent_node_id.is_none()
173                || node.budget.tokens_consumed != receipt.tokens_consumed
174                || node.budget.cost_micros_consumed != receipt.cost_micros_consumed
175                || (node.execution_fence.is_some()
176                    && receipt.execution_fence != node.execution_fence)
177            {
178                return Err(WorkGraphError::InvalidGraph(
179                    "receipt/node mismatch".to_string(),
180                ));
181            }
182        }
183        for (node_id, spend) in &self.failed_spend {
184            validate_measured_spend(spend)?;
185            let node = self
186                .nodes
187                .get(node_id)
188                .ok_or_else(|| WorkGraphError::InvalidGraph("orphan failed spend".into()))?;
189            if node_id != &spend.node_id
190                || node.parent_node_id.is_none()
191                || node.status != NodeStatus::Stopped
192                || node.stop_reason != Some(StopReason::BudgetExhausted)
193                || node.execution_fence.as_deref() != Some(spend.execution_fence.as_str())
194                || (spend.tokens_consumed <= node.budget.tokens_allocated
195                    && spend.cost_micros_consumed <= node.budget.cost_micros_allocated)
196                || self
197                    .receipts
198                    .values()
199                    .any(|receipt| receipt.node_id == *node_id)
200                || self.accepted_path.contains(node_id)
201            {
202                return Err(WorkGraphError::InvalidGraph(
203                    "failed spend/node mismatch".into(),
204                ));
205            }
206        }
207        for node_id in &self.accepted_path {
208            if !self.nodes.contains_key(node_id) {
209                return Err(WorkGraphError::InvalidGraph(
210                    "orphan accepted path".to_string(),
211                ));
212            }
213        }
214        let mut supported_path = BTreeSet::new();
215        for receipt in self.receipts.values() {
216            if receipt.outcome == ChildOutcome::Accepted
217                && self.accepted_path.contains(&receipt.node_id)
218            {
219                supported_path.extend(self.checked_lineage(&receipt.node_id)?);
220            }
221        }
222        if supported_path != self.accepted_path {
223            return Err(WorkGraphError::InvalidGraph(
224                "accepted path lacks accepted receipt lineage".to_string(),
225            ));
226        }
227        for (chain_id, chain) in &self.chain_budgets {
228            let root = self
229                .nodes
230                .get(chain_id)
231                .filter(|node| node.parent_node_id.is_none())
232                .ok_or_else(|| WorkGraphError::InvalidGraph("invalid chain root".to_string()))?;
233            let mut allocated_tokens = 0_u64;
234            let mut allocated_cost = 0_u64;
235            let mut consumed_tokens = 0_u64;
236            let mut consumed_cost = 0_u64;
237            let mut unauthorized_tokens = 0_u64;
238            let mut unauthorized_cost = 0_u64;
239            let mut depth = 0_u16;
240            for node in self.nodes.values() {
241                if self.checked_lineage(&node.node_id)?.first() != Some(chain_id) {
242                    continue;
243                }
244                allocated_tokens = allocated_tokens
245                    .checked_add(node.budget.tokens_allocated)
246                    .ok_or(WorkGraphError::Overflow)?;
247                allocated_cost = allocated_cost
248                    .checked_add(node.budget.cost_micros_allocated)
249                    .ok_or(WorkGraphError::Overflow)?;
250                consumed_tokens = consumed_tokens
251                    .checked_add(node.budget.tokens_consumed)
252                    .ok_or(WorkGraphError::Overflow)?;
253                consumed_cost = consumed_cost
254                    .checked_add(node.budget.cost_micros_consumed)
255                    .ok_or(WorkGraphError::Overflow)?;
256                depth = depth.max(node.depth);
257                if let Some(spend) = self.failed_spend.get(&node.node_id) {
258                    unauthorized_tokens = unauthorized_tokens.saturating_add(
259                        spend
260                            .tokens_consumed
261                            .saturating_sub(node.budget.tokens_allocated),
262                    );
263                    unauthorized_cost = unauthorized_cost.saturating_add(
264                        spend
265                            .cost_micros_consumed
266                            .saturating_sub(node.budget.cost_micros_allocated),
267                    );
268                }
269            }
270            for (child_id, budget) in &self.pending_child_budgets {
271                let Some(parent_id) = self.pending_child_parents.get(child_id) else {
272                    continue;
273                };
274                if self.checked_lineage(parent_id)?.first() == Some(chain_id) {
275                    let pending_depth = self.nodes[parent_id]
276                        .depth
277                        .checked_add(1)
278                        .ok_or(WorkGraphError::Overflow)?;
279                    depth = depth.max(pending_depth);
280                    allocated_tokens = allocated_tokens
281                        .checked_add(budget.tokens_allocated)
282                        .ok_or(WorkGraphError::Overflow)?;
283                    allocated_cost = allocated_cost
284                        .checked_add(budget.cost_micros_allocated)
285                        .ok_or(WorkGraphError::Overflow)?;
286                }
287            }
288            if chain.chain_id != *chain_id
289                || chain.root_budget_tokens != root.budget.tokens_allocated
290                || chain.root_budget_cost_micros != root.budget.cost_micros_allocated
291                || chain.total_allocated_tokens != allocated_tokens
292                || chain.total_allocated_cost_micros != allocated_cost
293                || chain.total_consumed_tokens != consumed_tokens
294                || chain.total_consumed_cost_micros != consumed_cost
295                || chain.unauthorized_tokens != unauthorized_tokens
296                || chain.unauthorized_cost_micros != unauthorized_cost
297                || chain.depth != depth
298                || consumed_tokens > chain.root_budget_tokens
299                || consumed_cost > chain.root_budget_cost_micros
300            {
301                return Err(WorkGraphError::InvalidGraph(
302                    "chain accounting mismatch".to_string(),
303                ));
304            }
305        }
306        Ok(())
307    }
308
309    pub fn root_agent_for(&self, node_id: &str) -> Result<&str, WorkGraphError> {
310        let root_id = self
311            .checked_lineage(node_id)?
312            .into_iter()
313            .next()
314            .ok_or_else(|| WorkGraphError::NodeNotFound(node_id.to_string()))?;
315        Ok(self.nodes[&root_id].agent_id.as_str())
316    }
317
318    fn checked_lineage(&self, node_id: &str) -> Result<Vec<String>, WorkGraphError> {
319        let mut lineage = Vec::new();
320        let mut seen = BTreeSet::new();
321        let mut current_id = Some(node_id);
322        while let Some(id) = current_id {
323            if !seen.insert(id.to_string()) {
324                return Err(WorkGraphError::InvalidGraph("cycle detected".to_string()));
325            }
326            let node = self
327                .nodes
328                .get(id)
329                .ok_or_else(|| WorkGraphError::NodeNotFound(id.to_string()))?;
330            lineage.push(id.to_string());
331            current_id = node.parent_node_id.as_deref();
332        }
333        lineage.reverse();
334        Ok(lineage)
335    }
336
337    /// Stop a node and all its descendants (cascade).
338    pub fn stop(
339        &mut self,
340        node_id: &str,
341        reason: StopReason,
342    ) -> Result<Vec<String>, WorkGraphError> {
343        if !self.nodes.contains_key(node_id) {
344            return Err(WorkGraphError::NodeNotFound(node_id.to_string()));
345        }
346        let mut stopped = Vec::new();
347        self.stop_recursive(node_id, reason, &mut stopped);
348        Ok(stopped)
349    }
350
351    /// Stop a node whose start was refused before the host received a launch:
352    /// no child process exists, so its paths are free at once.
353    pub fn stop_unlaunched(
354        &mut self,
355        node_id: &str,
356        reason: StopReason,
357    ) -> Result<Vec<String>, WorkGraphError> {
358        let stopped = self.stop(node_id, reason)?;
359        if let Some(node) = self.nodes.get_mut(node_id) {
360            node.lease_expires_epoch_ms = None;
361        }
362        Ok(stopped)
363    }
364
365    /// Record token consumption on a node.
366    pub fn consume_budget(
367        &mut self,
368        node_id: &str,
369        tokens: u64,
370        cost_micros: u64,
371    ) -> Result<bool, WorkGraphError> {
372        let (next_node_tokens, next_node_cost, exhausted) = {
373            let node = self
374                .nodes
375                .get(node_id)
376                .ok_or_else(|| WorkGraphError::NodeNotFound(node_id.to_string()))?;
377            if node.status != NodeStatus::Active {
378                return Err(WorkGraphError::InvalidTransition(node_id.to_string()));
379            }
380            let next_tokens = node
381                .budget
382                .tokens_consumed
383                .checked_add(tokens)
384                .ok_or(WorkGraphError::Overflow)?;
385            let next_cost = node
386                .budget
387                .cost_micros_consumed
388                .checked_add(cost_micros)
389                .ok_or(WorkGraphError::Overflow)?;
390            if next_tokens > node.budget.tokens_allocated
391                || next_cost > node.budget.cost_micros_allocated
392            {
393                return Err(WorkGraphError::InvalidBudget);
394            }
395            let (reserved_tokens, reserved_cost) = self.reserved_child_budget(node_id, None)?;
396            if tokens
397                > node
398                    .budget
399                    .tokens_remaining()
400                    .saturating_sub(reserved_tokens)
401                || cost_micros > node.budget.cost_remaining().saturating_sub(reserved_cost)
402            {
403                return Err(WorkGraphError::InvalidBudget);
404            }
405            (
406                next_tokens,
407                next_cost,
408                next_tokens == node.budget.tokens_allocated
409                    || next_cost == node.budget.cost_micros_allocated,
410            )
411        };
412        let chain_id = self
413            .chain_id_for_node(node_id)
414            .expect("node always has a root node");
415        let (next_chain_tokens, next_chain_cost, chain_exhausted) = {
416            let chain = self
417                .chain_budgets
418                .get(&chain_id)
419                .ok_or_else(|| WorkGraphError::InvalidGraph("missing chain budget".into()))?;
420            let next_tokens = chain
421                .total_consumed_tokens
422                .checked_add(tokens)
423                .ok_or(WorkGraphError::Overflow)?;
424            let next_cost = chain
425                .total_consumed_cost_micros
426                .checked_add(cost_micros)
427                .ok_or(WorkGraphError::Overflow)?;
428            if next_tokens.saturating_add(chain.unauthorized_tokens) > chain.root_budget_tokens
429                || next_cost.saturating_add(chain.unauthorized_cost_micros)
430                    > chain.root_budget_cost_micros
431            {
432                return Err(WorkGraphError::InvalidBudget);
433            }
434            (
435                next_tokens,
436                next_cost,
437                next_tokens.saturating_add(chain.unauthorized_tokens) == chain.root_budget_tokens
438                    || next_cost.saturating_add(chain.unauthorized_cost_micros)
439                        == chain.root_budget_cost_micros,
440            )
441        };
442
443        // Commit only after all node, child-reservation and chain checks pass.
444        let node = self.nodes.get_mut(node_id).expect("node checked above");
445        node.budget.tokens_consumed = next_node_tokens;
446        node.budget.cost_micros_consumed = next_node_cost;
447        let chain = self
448            .chain_budgets
449            .get_mut(&chain_id)
450            .expect("chain checked above");
451        chain.total_consumed_tokens = next_chain_tokens;
452        chain.total_consumed_cost_micros = next_chain_cost;
453
454        if chain_exhausted {
455            self.stop_recursive(&chain_id, StopReason::BudgetExhausted, &mut Vec::new());
456            return Ok(true);
457        }
458        if exhausted {
459            let node = self.nodes.get_mut(node_id).expect("node checked above");
460            node.status = NodeStatus::Stopped;
461            node.stop_reason = Some(StopReason::BudgetExhausted);
462            self.end_incarnation(node_id);
463            return Ok(true);
464        }
465
466        Ok(false)
467    }
468
469    /// Records token consumption for a node and updates its chain budget.
470    pub fn consume_tokens(&mut self, node_id: &str, tokens: u64) -> Result<(), WorkGraphError> {
471        self.consume_budget(node_id, tokens, 0)?;
472        Ok(())
473    }
474
475    /// Returns the chain budget for a given node's chain.
476    pub fn chain_budget_for(&self, node_id: &str) -> Option<&ChainBudget> {
477        let chain_id = self.chain_id_for_node(node_id)?;
478        self.chain_budgets.get(&chain_id)
479    }
480
481    /// Returns all chains at or above their utilization threshold.
482    pub fn over_budget_chains(&self, threshold_pct: f64) -> Vec<&ChainBudget> {
483        self.chain_budgets
484            .values()
485            .filter(|budget| budget.utilization_pct() >= threshold_pct)
486            .collect()
487    }
488
489    /// Check all nodes for stop conditions and cascade.
490    pub fn enforce_stop_conditions(&mut self) -> Vec<(String, StopReason)> {
491        let exhausted: Vec<String> = self
492            .nodes
493            .iter()
494            .filter(|(_, n)| n.status == NodeStatus::Active && n.budget.is_exhausted())
495            .map(|(id, _)| id.clone())
496            .collect();
497        let mut stopped = Vec::new();
498        for node_id in exhausted {
499            let mut cascade = Vec::new();
500            self.stop_recursive(&node_id, StopReason::BudgetExhausted, &mut cascade);
501            for id in cascade {
502                stopped.push((id, StopReason::BudgetExhausted));
503            }
504        }
505        stopped
506    }
507
508    pub fn get_node(&self, node_id: &str) -> Option<&WorkNode> {
509        self.nodes.get(node_id)
510    }
511
512    /// Advance only the current running incarnation; retries revoke the prior
513    /// attempt's delivery authority without changing the node claim fence.
514    pub fn begin_delivery_attempt(
515        &mut self,
516        node_id: &str,
517        fence: &str,
518        attempt: u8,
519    ) -> Result<(), WorkGraphError> {
520        let node = self
521            .nodes
522            .get_mut(node_id)
523            .ok_or_else(|| WorkGraphError::InvalidTransition(node_id.into()))?;
524        if node.status != NodeStatus::Active
525            || !node.execution_started
526            || node.execution_fence.as_deref() != Some(fence)
527            || !(1..=super::MAX_DELIVERY_ATTEMPTS).contains(&attempt)
528            || node.delivery_attempt.unwrap_or(0).checked_add(1) != Some(attempt)
529        {
530            return Err(WorkGraphError::InvalidTransition(node_id.into()));
531        }
532        node.delivery_attempt = Some(attempt);
533        Ok(())
534    }
535
536    pub fn contains_agent(&self, agent_id: &str) -> bool {
537        self.nodes.values().any(|node| node.agent_id == agent_id)
538    }
539
540    pub fn children_of(&self, node_id: &str) -> &[String] {
541        self.children.get(node_id).map_or(&[], Vec::as_slice)
542    }
543
544    /// A started incarnation holds its paths while it runs and, once stopped,
545    /// until its window ends: LeanCTX cannot observe the host's child exit, so
546    /// a stopped child keeps write exclusivity for the time it was granted.
547    pub fn has_live_path_conflict(
548        &self,
549        requested: &[String],
550        now_epoch_ms: u64,
551        except_node_id: Option<&str>,
552    ) -> bool {
553        self.nodes.values().any(|node| {
554            Some(node.node_id.as_str()) != except_node_id
555                && (self.execution_exit_unconfirmed(node)
556                    || (node.execution_started
557                        && node.status == NodeStatus::Stopped
558                        && node
559                            .lease_expires_epoch_ms
560                            .is_some_and(|expires| now_epoch_ms < expires)))
561                && node.path_claims.iter().any(|held| {
562                    requested
563                        .iter()
564                        .any(|claim| path_claims_overlap(held, claim))
565                })
566        })
567    }
568
569    pub fn active_count(&self) -> usize {
570        self.nodes
571            .values()
572            .filter(|n| n.status == NodeStatus::Active)
573            .count()
574    }
575
576    /// A started incarnation is live exactly while its node is active: the host
577    /// owns the child process, and every terminal transition (receipt, complete,
578    /// stop, budget exhaustion) ends LeanCTX's authority over it. Stores written
579    /// by the removed launcher may hold terminal nodes it never acknowledged;
580    /// they are ended too.
581    pub(super) fn execution_exit_unconfirmed(&self, node: &WorkNode) -> bool {
582        node.execution_started && node.status == NodeStatus::Active
583    }
584
585    /// Record the end of a started incarnation; earlier releases read this map
586    /// to free the slot, so it is still written.
587    pub(super) fn end_incarnation(&mut self, node_id: &str) {
588        let Some(node) = self.nodes.get_mut(node_id) else {
589            return;
590        };
591        if !node.execution_started {
592            node.lease_expires_epoch_ms = None;
593        } else if let Some(fence) = &node.execution_fence {
594            self.reaped_executions
595                .insert(node_id.to_string(), fence.clone());
596        }
597    }
598
599    pub fn total_count(&self) -> usize {
600        self.nodes.len()
601    }
602
603    #[allow(clippy::collapsible_if)]
604    fn stop_recursive(&mut self, node_id: &str, reason: StopReason, stopped: &mut Vec<String>) {
605        if let Some(node) = self.nodes.get_mut(node_id) {
606            if matches!(node.status, NodeStatus::Active | NodeStatus::Pending) {
607                node.status = NodeStatus::Stopped;
608                node.stop_reason = Some(reason);
609                // A started node keeps its lease expiry: it bounds the path
610                // exclusivity of a child that may still be running.
611                self.end_incarnation(node_id);
612                stopped.push(node_id.to_string());
613            }
614        }
615        let children: Vec<String> = self.children.get(node_id).cloned().unwrap_or_default();
616        for child_id in children {
617            self.stop_recursive(&child_id, StopReason::ParentStopped, stopped);
618        }
619    }
620
621    pub(super) fn chain_id_for_node(&self, node_id: &str) -> Option<String> {
622        let mut current_id = node_id;
623        let mut current = self.nodes.get(current_id)?;
624        while let Some(parent_id) = current.parent_node_id.as_deref() {
625            current_id = parent_id;
626            current = self.nodes.get(current_id)?;
627        }
628        Some(current_id.to_string())
629    }
630
631    pub(super) fn node_lineage(&self, node_id: &str) -> Vec<String> {
632        let mut lineage = Vec::new();
633        let mut current_id = Some(node_id);
634        while let Some(id) = current_id {
635            let Some(node) = self.nodes.get(id) else {
636                break;
637            };
638            lineage.push(id.to_string());
639            current_id = node.parent_node_id.as_deref();
640        }
641        lineage.reverse();
642        lineage
643    }
644
645    pub(super) fn reserved_child_budget(
646        &self,
647        parent_id: &str,
648        exclude_child_id: Option<&str>,
649    ) -> Result<(u64, u64), WorkGraphError> {
650        let mut tokens = 0_u64;
651        let mut cost = 0_u64;
652        let mut pending_parents = vec![parent_id];
653        let mut visited = BTreeSet::from([parent_id]);
654        while let Some(current_parent) = pending_parents.pop() {
655            for child_id in self.children.get(current_parent).into_iter().flatten() {
656                if current_parent == parent_id && Some(child_id.as_str()) == exclude_child_id {
657                    continue;
658                }
659                if !visited.insert(child_id.as_str()) {
660                    return Err(WorkGraphError::InvalidGraph(
661                        "duplicate child or cycle".into(),
662                    ));
663                }
664                let child = self
665                    .nodes
666                    .get(child_id)
667                    .ok_or_else(|| WorkGraphError::InvalidGraph("dangling child".to_string()))?;
668                // A terminal status alone does not settle usage: complete() can run
669                // without a receipt, and cancellation can leave provider usage unknown.
670                let settled = matches!(child.status, NodeStatus::Completed | NodeStatus::Failed)
671                    // Only measured usage can free an allocation: unfenced legacy
672                    // receipts and figures the child or its host reported (every
673                    // receipt since LeanCTX stopped launching agents) keep the full
674                    // allocation reserved, so under-reporting cannot be re-delegated.
675                    && child.execution_fence.is_some()
676                    && self.receipts.values().any(|receipt| {
677                        receipt.node_id == *child_id
678                            && receipt.usage_source == super::UsageSource::Measured
679                            && receipt.tokens_consumed == child.budget.tokens_consumed
680                            && receipt.cost_micros_consumed == child.budget.cost_micros_consumed
681                            && receipt.execution_fence == child.execution_fence
682                    });
683                let (child_tokens, child_cost) = if settled {
684                    // Keep actual spend and all descendant commitments reserved; only
685                    // the settled node's unused allocation becomes available again.
686                    pending_parents.push(child_id.as_str());
687                    (
688                        child.budget.tokens_consumed,
689                        child.budget.cost_micros_consumed,
690                    )
691                } else {
692                    (
693                        child.budget.tokens_allocated,
694                        child.budget.cost_micros_allocated,
695                    )
696                };
697                tokens = tokens
698                    .checked_add(child_tokens)
699                    .ok_or(WorkGraphError::Overflow)?;
700                cost = cost
701                    .checked_add(child_cost)
702                    .ok_or(WorkGraphError::Overflow)?;
703            }
704            for (child_id, budget) in &self.pending_child_budgets {
705                if (current_parent == parent_id && Some(child_id.as_str()) == exclude_child_id)
706                    || self.pending_child_parents.get(child_id).map(String::as_str)
707                        != Some(current_parent)
708                {
709                    continue;
710                }
711                tokens = tokens
712                    .checked_add(budget.tokens_allocated)
713                    .ok_or(WorkGraphError::Overflow)?;
714                cost = cost
715                    .checked_add(budget.cost_micros_allocated)
716                    .ok_or(WorkGraphError::Overflow)?;
717            }
718        }
719        Ok((tokens, cost))
720    }
721
722    pub(super) fn ensure_chain_budget(&mut self, chain_id: &str) {
723        let root_budget = self
724            .nodes
725            .get(chain_id)
726            .map(|node| node.budget.clone())
727            .unwrap_or(WorkNodeBudget {
728                tokens_allocated: 0,
729                tokens_consumed: 0,
730                cost_micros_allocated: 0,
731                cost_micros_consumed: 0,
732            });
733        self.chain_budgets
734            .entry(chain_id.to_string())
735            .or_insert(ChainBudget {
736                chain_id: chain_id.to_string(),
737                root_budget_tokens: root_budget.tokens_allocated,
738                total_consumed_tokens: 0,
739                total_allocated_tokens: root_budget.tokens_allocated,
740                root_budget_cost_micros: root_budget.cost_micros_allocated,
741                total_consumed_cost_micros: 0,
742                total_allocated_cost_micros: root_budget.cost_micros_allocated,
743                unauthorized_tokens: 0,
744                unauthorized_cost_micros: 0,
745                depth: 0,
746            });
747    }
748
749    pub(super) fn record_chain_allocation(
750        &mut self,
751        chain_id: &str,
752        previous_tokens: u64,
753        tokens_allocated: u64,
754        previous_cost_micros: u64,
755        cost_micros_allocated: u64,
756        depth: u16,
757    ) {
758        self.ensure_chain_budget(chain_id);
759        let chain = self
760            .chain_budgets
761            .get_mut(chain_id)
762            .expect("chain budget initialized");
763        chain.total_allocated_tokens = chain
764            .total_allocated_tokens
765            .saturating_sub(previous_tokens)
766            .saturating_add(tokens_allocated);
767        chain.total_allocated_cost_micros = chain
768            .total_allocated_cost_micros
769            .saturating_sub(previous_cost_micros)
770            .saturating_add(cost_micros_allocated);
771        chain.depth = chain.depth.max(depth);
772    }
773}