1use 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 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 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 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 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 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 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 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 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 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 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 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 pub(super) fn execution_exit_unconfirmed(&self, node: &WorkNode) -> bool {
582 node.execution_started && node.status == NodeStatus::Active
583 }
584
585 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 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 let settled = matches!(child.status, NodeStatus::Completed | NodeStatus::Failed)
671 && 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 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}