Skip to main content

warble_claude_code/
conditional.rs

1//! Deterministic realization of a `conditional` step's closed-vocabulary `when` guard — mirrors
2//! `dispatcher/claude-agent-sdk/src/conditional.ts` field-for-field (see that module's doc for the
3//! full R1 repair-fold-into-loop / R2 guarded-skip design).
4//!
5//! This module is deliberately **not** wired into this back-end's actual static-file emission
6//! (`emit.rs`): the Claude Code CLI target has no deterministic runtime, so its conditional/repair
7//! behavior is entirely textual — the live CLI agent judges the guard emergently from the emitted
8//! prompt text (see `ir.rs`'s `LlmCall::when` doc comment), not from an evaluator like this one.
9//! No existing profile calls into this module; it exists solely so the two back-ends' understanding
10//! of the SAME closed guard vocabulary can be held to a shared conformance fixture
11//! (`dispatcher/conformance-fixtures/conditional.json`, asserted against here and against
12//! `conditional.ts`) — a semantic drift between them fails whichever suite moved.
13
14use crate::error::DispatchError;
15use crate::ir::WhenGuard;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum StepOutcome {
22    Success,
23    Failure,
24}
25
26/// The subset of a step's identity this module needs: its name (an `on_failure` target) and the
27/// artifact its output would land in (an `on_failure`-repair shape also requires it to be consumed).
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct StepIdentity {
30    pub name: String,
31    pub produces: Option<String>,
32}
33
34#[derive(Debug, Clone, Default)]
35pub struct GuardState {
36    /// Every artifact produced by steps run so far, keyed by their `produces` name.
37    ///
38    /// Named `artifacts` rather than `slots` because IR 0.7 uses "slot" for a named position in
39    /// prompt text with alternative wordings — a different thing entirely. The spec has always
40    /// called these artifacts: `on_flag`'s target is documented as a dotted `artifact.field`.
41    pub artifacts: HashMap<String, String>,
42    /// Every step's outcome recorded so far, keyed by step name.
43    pub outcomes: HashMap<String, StepOutcome>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "lowercase")]
48pub enum ConditionalDecision {
49    Run,
50    Skip,
51    Repair { target: StepIdentity },
52}
53
54/// Default bound on repair attempts. Not (yet) an IR field — `max_attempts` is not part of the
55/// schema either back-end reads — so this mirrors conditional.ts's back-end-local runtime constant.
56pub const DEFAULT_MAX_REPAIR_ATTEMPTS: usize = 1;
57
58/// Resolve a dotted `artifact.field.nested` path against the parsed JSON of the named artifact.
59/// Any failure along the way (artifact absent, not JSON, path doesn't resolve) reads as
60/// `false`/absent — a guard never fails on a shape mismatch, it just doesn't fire.
61fn read_flag(artifacts: &HashMap<String, String>, target: &str) -> bool {
62    let mut parts = target.split('.');
63    let Some(artifact_name) = parts.next() else {
64        return false;
65    };
66    let Some(raw) = artifacts.get(artifact_name) else {
67        return false;
68    };
69    let Ok(mut cur) = serde_json::from_str::<serde_json::Value>(raw) else {
70        return false;
71    };
72    for key in parts {
73        let serde_json::Value::Object(map) = &cur else {
74            return false;
75        };
76        match map.get(key) {
77            Some(v) => cur = v.clone(),
78            None => return false,
79        }
80    }
81    matches!(cur, serde_json::Value::Bool(true))
82}
83
84/// Evaluate a guard's truth value directly (R2 — guarded-skip). Does not special-case the R1 repair
85/// shape; callers that need to distinguish repair-fold from a plain `on_failure` skip should use
86/// [`classify_conditional_step`] instead.
87pub fn evaluate_guard(when: &WhenGuard, state: &GuardState) -> Result<bool, DispatchError> {
88    match when.guard.as_str() {
89        "on_failure" => Ok(state.outcomes.get(&when.target) == Some(&StepOutcome::Failure)),
90        "on_flag" => Ok(read_flag(&state.artifacts, &when.target)),
91        "on_missing" => Ok(!state.artifacts.contains_key(&when.target)),
92        // The compiler validates guard names against the closed vocabulary before this IR ever
93        // reaches a back-end (core/src/compile.rs); an unrecognized value here means a hand-edited
94        // or future-versioned IR slipped through — loud-fail rather than silently treating it as false.
95        other => Err(DispatchError::new(format!(
96            "unknown guard '{other}' (closed vocabulary: on_failure, on_flag, on_missing)"
97        ))),
98    }
99}
100
101/// Structural test for the R1 repair shape: an `on_failure` guard whose target is `preceding_step`,
102/// where that step's sole produced artifact is also consumed by the conditional step. Returns the
103/// target's identity when the shape matches, else `None` (falls back to R2 guarded-skip).
104pub fn repair_fold_target(
105    when: &WhenGuard,
106    consumes: &[String],
107    preceding_step: Option<&StepIdentity>,
108) -> Option<StepIdentity> {
109    let preceding = preceding_step?;
110    if when.guard != "on_failure" || when.target != preceding.name {
111        return None;
112    }
113    let produces = preceding.produces.as_ref()?;
114    if !consumes.iter().any(|c| c == produces) {
115        return None;
116    }
117    Some(preceding.clone())
118}
119
120/// The single entry point a staged executor would use to decide what a conditional step does next:
121/// fold into a bounded repair turn (R1), run (R2 guard true), or skip (R2 guard false / R1 target
122/// didn't fail).
123pub fn classify_conditional_step(
124    when: &WhenGuard,
125    consumes: &[String],
126    preceding_step: Option<&StepIdentity>,
127    state: &GuardState,
128) -> Result<ConditionalDecision, DispatchError> {
129    if let Some(target) = repair_fold_target(when, consumes, preceding_step) {
130        return Ok(
131            if state.outcomes.get(&target.name) == Some(&StepOutcome::Failure) {
132                ConditionalDecision::Repair { target }
133            } else {
134                ConditionalDecision::Skip
135            },
136        );
137    }
138    Ok(if evaluate_guard(when, state)? {
139        ConditionalDecision::Run
140    } else {
141        ConditionalDecision::Skip
142    })
143}
144
145/// Drive a bounded repair loop: call `attempt_failed` up to `max_attempts` times (1-indexed),
146/// stopping at the first attempt that reports success. Mirrors conditional.ts's `runRepairLoop`:
147/// returns `(recovered, attempts)` and never loops past `max_attempts` — exhaustion is reported back
148/// (`recovered = false`) for the caller to loud-fail, not swallowed here.
149pub fn run_repair_loop(
150    max_attempts: usize,
151    mut attempt_failed: impl FnMut(usize) -> bool,
152) -> (bool, usize) {
153    for attempt in 1..=max_attempts {
154        if !attempt_failed(attempt) {
155            return (true, attempt);
156        }
157    }
158    (false, max_attempts)
159}