Skip to main content

runifold_workflow/
remediation.rs

1use std::{future::Future, pin::Pin, sync::Arc};
2
3use runifold_core::{CapabilitySet, ChildEvent, EventId, RunContext, RunEventKind};
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6use thiserror::Error;
7
8use crate::checkpoint::WorkflowCheckpointCursor;
9use crate::execution::{
10    check_lifecycle, record_domain, save_checkpoint, validate_exact_usage, validate_usage_floor,
11};
12use crate::workflow::WorkflowNode;
13use crate::{
14    StepId, WorkflowCheckpointPhase, WorkflowCheckpointState, WorkflowError, WorkflowResumePolicy,
15    WorkflowStep,
16};
17
18const MAX_REVIEW_REASON_BYTES: usize = 4_096;
19const MAX_REVIEW_FEEDBACK_BYTES: usize = 1_048_576;
20
21/// Bounded repair policy for one reviewable workflow step.
22#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
23pub struct WorkflowRemediationPolicy {
24    max_repairs: u32,
25}
26
27impl WorkflowRemediationPolicy {
28    /// Creates a policy allowing at most `max_repairs` additional generations.
29    pub const fn new(max_repairs: u32) -> Self {
30        Self { max_repairs }
31    }
32
33    /// Returns the number of additional generations allowed after the first.
34    pub const fn max_repairs(self) -> u32 {
35        self.max_repairs
36    }
37}
38
39/// Canonical request presented to an application-owned output reviewer.
40#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
41pub struct WorkflowReviewRequest {
42    /// Stable workflow node under review.
43    pub step: StepId,
44    /// One-based generation attempt.
45    pub attempt: u32,
46    /// Original value supplied to the repairable node.
47    pub original_input: Value,
48    /// Candidate produced by the current generation attempt.
49    pub candidate: Value,
50}
51
52/// Application-owned decision over one generated candidate.
53#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
54#[serde(tag = "kind", rename_all = "snake_case")]
55#[non_exhaustive]
56pub enum WorkflowReviewVerdict {
57    /// Accept the candidate as the node's stable output.
58    Approve,
59    /// Generate another candidate with structured reviewer feedback.
60    Repair {
61        /// Application-defined findings or repair instructions.
62        feedback: Value,
63    },
64    /// Permanently reject the candidate.
65    Reject {
66        /// Bounded operator-facing explanation.
67        reason: String,
68    },
69}
70
71impl WorkflowReviewVerdict {
72    /// Creates an approval decision.
73    pub const fn approve() -> Self {
74        Self::Approve
75    }
76
77    /// Creates a repair decision with application-defined feedback.
78    ///
79    /// # Errors
80    ///
81    /// Rejects feedback whose canonical JSON representation exceeds 1 MiB.
82    pub fn repair(feedback: Value) -> Result<Self, WorkflowReviewError> {
83        validate_feedback(&feedback)?;
84        Ok(Self::Repair { feedback })
85    }
86
87    /// Creates a permanent rejection with a safe explanation.
88    ///
89    /// # Errors
90    ///
91    /// Rejects blank explanations and values above 4 KiB.
92    pub fn reject(reason: impl Into<String>) -> Result<Self, WorkflowReviewError> {
93        let reason = reason.into();
94        validate_reason(&reason)?;
95        Ok(Self::Reject { reason })
96    }
97
98    fn validate(&self) -> Result<(), WorkflowReviewError> {
99        match self {
100            Self::Approve => Ok(()),
101            Self::Repair { feedback } => validate_feedback(feedback),
102            Self::Reject { reason } => validate_reason(reason),
103        }
104    }
105}
106
107/// Failure returned by an application-owned output reviewer.
108#[derive(Clone, Debug, Error, Eq, PartialEq)]
109#[non_exhaustive]
110pub enum WorkflowReviewError {
111    /// Reviewer construction or composition was invalid.
112    #[error("invalid workflow reviewer configuration: {0}")]
113    InvalidConfiguration(String),
114    /// A reviewer returned a structurally valid but semantically inconsistent decision.
115    #[error("invalid workflow review decision: {0}")]
116    InvalidDecision(String),
117    /// Reviewer feedback exceeded the durable checkpoint limit.
118    #[error("workflow review feedback exceeds the 1 MiB durable limit")]
119    FeedbackTooLarge,
120    /// A rejection reason was blank or exceeded its durable limit.
121    #[error("workflow review rejection reason must contain 1..=4096 bytes")]
122    InvalidRejectionReason,
123    /// The reviewer could not evaluate the candidate.
124    #[error("workflow review failed: {0}")]
125    Execution(String),
126}
127
128/// Boxed asynchronous reviewer result.
129#[cfg(not(target_arch = "wasm32"))]
130pub type WorkflowReviewFuture<'a> =
131    Pin<Box<dyn Future<Output = Result<WorkflowReviewVerdict, WorkflowReviewError>> + Send + 'a>>;
132
133/// Boxed asynchronous reviewer result on single-threaded WASM.
134#[cfg(target_arch = "wasm32")]
135pub type WorkflowReviewFuture<'a> =
136    Pin<Box<dyn Future<Output = Result<WorkflowReviewVerdict, WorkflowReviewError>> + 'a>>;
137
138/// Provider-neutral review boundary for a repairable workflow step.
139pub trait WorkflowReviewer: Send + Sync {
140    /// Reviews one candidate without owning remediation or retry policy.
141    fn review<'a>(
142        &'a self,
143        request: WorkflowReviewRequest,
144        run: &'a RunContext,
145    ) -> WorkflowReviewFuture<'a>;
146}
147
148/// Structured input supplied to the second and later generation attempts.
149///
150/// `input` is a model-ready textual prompt, so an [`crate::AgentStep`] can
151/// consume this value directly. Custom steps can instead inspect the complete
152/// original input, previous candidate, feedback, and attempt fields.
153#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
154pub struct WorkflowRepairInput {
155    /// Model-ready prompt containing trusted runtime repair context.
156    pub input: String,
157    /// Original value supplied to the repairable node.
158    pub original_input: Value,
159    /// Candidate rejected by the preceding review.
160    pub previous_candidate: Value,
161    /// Application-defined reviewer findings or repair instructions.
162    pub feedback: Value,
163    /// One-based generation attempt receiving this input.
164    pub attempt: u32,
165}
166
167impl WorkflowRepairInput {
168    fn new(
169        original_input: Value,
170        previous_candidate: Value,
171        feedback: Value,
172        attempt: u32,
173    ) -> Self {
174        let original_prompt = model_prompt(&original_input);
175        let model_candidate = model_visible_candidate(&previous_candidate);
176        let instruction = json!({
177            "attempt": attempt,
178            "previous_candidate": model_candidate,
179            "feedback": feedback,
180            "instruction": "Produce a corrected candidate that addresses the reviewer feedback. Do not claim the previous candidate was accepted.",
181        });
182        let input = format!(
183            "{original_prompt}\n<runifold_workflow_repair trust=\"runtime\">{instruction}</runifold_workflow_repair>"
184        );
185        Self {
186            input,
187            original_input,
188            previous_candidate,
189            feedback,
190            attempt,
191        }
192    }
193}
194
195/// Durable substate of one repairable workflow node.
196#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
197#[serde(tag = "state", rename_all = "snake_case")]
198#[non_exhaustive]
199pub enum WorkflowRemediationCheckpoint {
200    /// The next generation input is durable and has not started.
201    GenerationReady {
202        /// Canonical input for the pending generation.
203        input: Value,
204    },
205    /// A generation may have partially executed.
206    GenerationInFlight {
207        /// Canonical input supplied to the generation attempt.
208        input: Value,
209    },
210    /// A generated candidate is durable and has not entered review.
211    ReviewReady {
212        /// Durable candidate awaiting review.
213        candidate: Value,
214    },
215    /// Review of a durable candidate may have partially executed.
216    ReviewInFlight {
217        /// Durable candidate supplied to the reviewer.
218        candidate: Value,
219    },
220    /// Review accepted a durable candidate before the outer node commit.
221    Approved {
222        /// Accepted output awaiting the outer node commit.
223        output: Value,
224    },
225    /// Review permanently rejected a candidate.
226    Rejected {
227        /// Candidate rejected by the reviewer.
228        candidate: Value,
229        /// Safe reviewer explanation.
230        reason: String,
231    },
232    /// The configured repair limit was exhausted.
233    Exhausted {
234        /// Last generated candidate.
235        candidate: Value,
236        /// Feedback that would have required another generation.
237        feedback: Value,
238    },
239}
240
241pub(crate) struct RepairableNode {
242    pub(crate) generator: Arc<dyn WorkflowStep>,
243    pub(crate) reviewer: Arc<dyn WorkflowReviewer>,
244    pub(crate) reviewer_capabilities: CapabilitySet,
245    pub(crate) policy: WorkflowRemediationPolicy,
246}
247
248pub(crate) fn prepare_remediation_resume(
249    state: &mut WorkflowCheckpointState,
250    run: &RunContext,
251    policy: WorkflowResumePolicy,
252) -> Result<(), WorkflowError> {
253    let WorkflowCheckpointPhase::Remediating {
254        step,
255        attempt,
256        original_input,
257        checkpoint,
258    } = state.phase.clone()
259    else {
260        return Err(WorkflowError::CheckpointIdentityMismatch);
261    };
262    match checkpoint {
263        WorkflowRemediationCheckpoint::GenerationInFlight { input } => {
264            require_retry_authority(&step, policy)?;
265            validate_usage_floor(state.usage, run.budget().usage())?;
266            state.usage = run.budget().usage();
267            state.phase = WorkflowCheckpointPhase::Remediating {
268                step,
269                attempt,
270                original_input,
271                checkpoint: WorkflowRemediationCheckpoint::GenerationReady { input },
272            };
273        }
274        WorkflowRemediationCheckpoint::ReviewInFlight { candidate } => {
275            require_retry_authority(&step, policy)?;
276            validate_usage_floor(state.usage, run.budget().usage())?;
277            state.usage = run.budget().usage();
278            state.phase = WorkflowCheckpointPhase::Remediating {
279                step,
280                attempt,
281                original_input,
282                checkpoint: WorkflowRemediationCheckpoint::ReviewReady { candidate },
283            };
284        }
285        WorkflowRemediationCheckpoint::Rejected { reason, .. } => {
286            validate_exact_usage(state.usage, run.budget().usage())?;
287            return Err(WorkflowError::RemediationRejected {
288                step,
289                attempts: attempt,
290                reason,
291            });
292        }
293        WorkflowRemediationCheckpoint::Exhausted { .. } => {
294            validate_exact_usage(state.usage, run.budget().usage())?;
295            return Err(WorkflowError::RemediationExhausted {
296                step,
297                attempts: attempt,
298            });
299        }
300        WorkflowRemediationCheckpoint::GenerationReady { .. }
301        | WorkflowRemediationCheckpoint::ReviewReady { .. }
302        | WorkflowRemediationCheckpoint::Approved { .. } => {
303            validate_exact_usage(state.usage, run.budget().usage())?;
304        }
305    }
306    Ok(())
307}
308
309fn require_retry_authority(
310    step: &StepId,
311    policy: WorkflowResumePolicy,
312) -> Result<(), WorkflowError> {
313    if policy == WorkflowResumePolicy::RejectAmbiguous {
314        Err(WorkflowError::AmbiguousCheckpoint { step: step.clone() })
315    } else {
316        Ok(())
317    }
318}
319
320pub(crate) async fn execute_repairable_node(
321    workflow: &str,
322    node: &WorkflowNode,
323    repairable: &RepairableNode,
324    state: &mut WorkflowCheckpointState,
325    run: &RunContext,
326    caused_by: Option<EventId>,
327    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
328) -> Result<Value, WorkflowError> {
329    let (mut attempt, original_input, mut remediation) = remediation_state(node, state)?;
330    loop {
331        check_lifecycle(run)?;
332        let context = RemediationAttemptContext {
333            workflow,
334            node,
335            repairable,
336            run,
337            caused_by,
338            attempt,
339            original_input: &original_input,
340        };
341        match remediation {
342            WorkflowRemediationCheckpoint::GenerationReady { input } => {
343                remediation = generate_candidate(&context, state, checkpoint, input).await?;
344            }
345            WorkflowRemediationCheckpoint::ReviewReady { candidate } => {
346                match review_candidate(&context, state, checkpoint, candidate).await? {
347                    RemediationAction::Continue {
348                        next_attempt,
349                        checkpoint,
350                    } => {
351                        attempt = next_attempt;
352                        remediation = checkpoint;
353                    }
354                    RemediationAction::Complete(output) => return Ok(output),
355                }
356            }
357            WorkflowRemediationCheckpoint::Approved { output } => return Ok(output),
358            WorkflowRemediationCheckpoint::Rejected { reason, .. } => {
359                return Err(WorkflowError::RemediationRejected {
360                    step: node.id.clone(),
361                    attempts: attempt,
362                    reason,
363                });
364            }
365            WorkflowRemediationCheckpoint::Exhausted { .. } => {
366                return Err(WorkflowError::RemediationExhausted {
367                    step: node.id.clone(),
368                    attempts: attempt,
369                });
370            }
371            WorkflowRemediationCheckpoint::GenerationInFlight { .. }
372            | WorkflowRemediationCheckpoint::ReviewInFlight { .. } => {
373                return Err(WorkflowError::CheckpointIdentityMismatch);
374            }
375        }
376    }
377}
378
379enum RemediationAction {
380    Continue {
381        next_attempt: u32,
382        checkpoint: WorkflowRemediationCheckpoint,
383    },
384    Complete(Value),
385}
386
387struct RemediationAttemptContext<'a> {
388    workflow: &'a str,
389    node: &'a WorkflowNode,
390    repairable: &'a RepairableNode,
391    run: &'a RunContext,
392    caused_by: Option<EventId>,
393    attempt: u32,
394    original_input: &'a Value,
395}
396
397async fn generate_candidate(
398    context: &RemediationAttemptContext<'_>,
399    state: &mut WorkflowCheckpointState,
400    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
401    input: Value,
402) -> Result<WorkflowRemediationCheckpoint, WorkflowError> {
403    state.phase = remediation_phase(
404        context.node,
405        context.attempt,
406        context.original_input.clone(),
407        WorkflowRemediationCheckpoint::GenerationInFlight {
408            input: input.clone(),
409        },
410    );
411    state.usage = context.run.budget().usage();
412    save_checkpoint(checkpoint, state).await?;
413    let candidate = execute_generation(
414        context.workflow,
415        context.node,
416        context.repairable,
417        input,
418        context.attempt,
419        context.run,
420        context.caused_by,
421    )
422    .await?;
423    let remediation = WorkflowRemediationCheckpoint::ReviewReady {
424        candidate: candidate.clone(),
425    };
426    state.phase = remediation_phase(
427        context.node,
428        context.attempt,
429        context.original_input.clone(),
430        remediation.clone(),
431    );
432    state.usage = context.run.budget().usage();
433    save_checkpoint(checkpoint, state).await?;
434    Ok(remediation)
435}
436
437async fn review_candidate(
438    context: &RemediationAttemptContext<'_>,
439    state: &mut WorkflowCheckpointState,
440    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
441    candidate: Value,
442) -> Result<RemediationAction, WorkflowError> {
443    state.phase = remediation_phase(
444        context.node,
445        context.attempt,
446        context.original_input.clone(),
447        WorkflowRemediationCheckpoint::ReviewInFlight {
448            candidate: candidate.clone(),
449        },
450    );
451    state.usage = context.run.budget().usage();
452    save_checkpoint(checkpoint, state).await?;
453    let verdict = execute_review(
454        context.workflow,
455        context.node,
456        context.repairable,
457        WorkflowReviewRequest {
458            step: context.node.id.clone(),
459            attempt: context.attempt,
460            original_input: context.original_input.clone(),
461            candidate: candidate.clone(),
462        },
463        context.run,
464        context.caused_by,
465    )
466    .await?;
467    apply_review_verdict(context, state, checkpoint, candidate, verdict).await
468}
469
470async fn apply_review_verdict(
471    context: &RemediationAttemptContext<'_>,
472    state: &mut WorkflowCheckpointState,
473    checkpoint: &mut Option<&mut WorkflowCheckpointCursor>,
474    candidate: Value,
475    verdict: WorkflowReviewVerdict,
476) -> Result<RemediationAction, WorkflowError> {
477    match verdict {
478        WorkflowReviewVerdict::Approve => {
479            state.phase = remediation_phase(
480                context.node,
481                context.attempt,
482                context.original_input.clone(),
483                WorkflowRemediationCheckpoint::Approved {
484                    output: candidate.clone(),
485                },
486            );
487            state.usage = context.run.budget().usage();
488            save_checkpoint(checkpoint, state).await?;
489            Ok(RemediationAction::Complete(candidate))
490        }
491        WorkflowReviewVerdict::Reject { reason } => {
492            state.phase = remediation_phase(
493                context.node,
494                context.attempt,
495                context.original_input.clone(),
496                WorkflowRemediationCheckpoint::Rejected {
497                    candidate,
498                    reason: reason.clone(),
499                },
500            );
501            state.usage = context.run.budget().usage();
502            save_checkpoint(checkpoint, state).await?;
503            Err(WorkflowError::RemediationRejected {
504                step: context.node.id.clone(),
505                attempts: context.attempt,
506                reason,
507            })
508        }
509        WorkflowReviewVerdict::Repair { feedback } => {
510            if context.attempt >= context.repairable.policy.max_repairs().saturating_add(1) {
511                state.phase = remediation_phase(
512                    context.node,
513                    context.attempt,
514                    context.original_input.clone(),
515                    WorkflowRemediationCheckpoint::Exhausted {
516                        candidate,
517                        feedback,
518                    },
519                );
520                state.usage = context.run.budget().usage();
521                save_checkpoint(checkpoint, state).await?;
522                return Err(WorkflowError::RemediationExhausted {
523                    step: context.node.id.clone(),
524                    attempts: context.attempt,
525                });
526            }
527            let next_attempt = context
528                .attempt
529                .checked_add(1)
530                .ok_or(WorkflowError::CheckpointIdentityMismatch)?;
531            let repair_input = WorkflowRepairInput::new(
532                context.original_input.clone(),
533                candidate,
534                feedback,
535                next_attempt,
536            );
537            let remediation = WorkflowRemediationCheckpoint::GenerationReady {
538                input: serde_json::to_value(repair_input)?,
539            };
540            state.phase = remediation_phase(
541                context.node,
542                next_attempt,
543                context.original_input.clone(),
544                remediation.clone(),
545            );
546            state.usage = context.run.budget().usage();
547            save_checkpoint(checkpoint, state).await?;
548            Ok(RemediationAction::Continue {
549                next_attempt,
550                checkpoint: remediation,
551            })
552        }
553    }
554}
555
556fn remediation_state(
557    node: &WorkflowNode,
558    state: &WorkflowCheckpointState,
559) -> Result<(u32, Value, WorkflowRemediationCheckpoint), WorkflowError> {
560    match &state.phase {
561        WorkflowCheckpointPhase::Ready => Ok((
562            1,
563            state.value.clone(),
564            WorkflowRemediationCheckpoint::GenerationReady {
565                input: state.value.clone(),
566            },
567        )),
568        WorkflowCheckpointPhase::Remediating {
569            step,
570            attempt,
571            original_input,
572            checkpoint,
573        } if *step == node.id => Ok((*attempt, original_input.clone(), checkpoint.clone())),
574        _ => Err(WorkflowError::CheckpointIdentityMismatch),
575    }
576}
577
578fn remediation_phase(
579    node: &WorkflowNode,
580    attempt: u32,
581    original_input: Value,
582    checkpoint: WorkflowRemediationCheckpoint,
583) -> WorkflowCheckpointPhase {
584    WorkflowCheckpointPhase::Remediating {
585        step: node.id.clone(),
586        attempt,
587        original_input,
588        checkpoint,
589    }
590}
591
592async fn execute_generation(
593    workflow: &str,
594    node: &WorkflowNode,
595    repairable: &RepairableNode,
596    input: Value,
597    attempt: u32,
598    run: &RunContext,
599    caused_by: Option<EventId>,
600) -> Result<Value, WorkflowError> {
601    let started = record_domain(
602        run,
603        "remediation.generation.started",
604        json!({
605            "workflow": workflow,
606            "step": node.id,
607            "attempt": attempt,
608        }),
609        caused_by,
610    )?;
611    let mut child = run.child(node.capabilities.clone()).map_err(|error| {
612        WorkflowError::AuthorityEscalation {
613            step: node.id.clone(),
614            capability: error.capability,
615        }
616    })?;
617    if let Some(event_id) = started {
618        child = child.with_cause(event_id);
619    }
620    run.record(
621        RunEventKind::Child(ChildEvent::Started {
622            child_run_id: child.run_id(),
623        }),
624        started,
625    )?;
626    match repairable.generator.execute(input, &child).await {
627        Ok(output) => {
628            run.record(
629                RunEventKind::Child(ChildEvent::Completed {
630                    child_run_id: child.run_id(),
631                }),
632                started,
633            )?;
634            record_domain(
635                run,
636                "remediation.generation.completed",
637                json!({
638                    "workflow": workflow,
639                    "step": node.id,
640                    "attempt": attempt,
641                }),
642                started,
643            )?;
644            Ok(output)
645        }
646        Err(source) => {
647            run.record(
648                RunEventKind::Child(ChildEvent::Failed {
649                    child_run_id: child.run_id(),
650                }),
651                started,
652            )?;
653            Err(WorkflowError::Step {
654                step: node.id.clone(),
655                source: Box::new(source),
656            })
657        }
658    }
659}
660
661async fn execute_review(
662    workflow: &str,
663    node: &WorkflowNode,
664    repairable: &RepairableNode,
665    request: WorkflowReviewRequest,
666    run: &RunContext,
667    caused_by: Option<EventId>,
668) -> Result<WorkflowReviewVerdict, WorkflowError> {
669    let attempt = request.attempt;
670    let started = record_domain(
671        run,
672        "remediation.review.started",
673        json!({
674            "workflow": workflow,
675            "step": node.id,
676            "attempt": attempt,
677        }),
678        caused_by,
679    )?;
680    let mut child = run
681        .child(repairable.reviewer_capabilities.clone())
682        .map_err(|error| WorkflowError::AuthorityEscalation {
683            step: node.id.clone(),
684            capability: error.capability,
685        })?;
686    if let Some(event_id) = started {
687        child = child.with_cause(event_id);
688    }
689    run.record(
690        RunEventKind::Child(ChildEvent::Started {
691            child_run_id: child.run_id(),
692        }),
693        started,
694    )?;
695    let verdict = repairable
696        .reviewer
697        .review(request, &child)
698        .await
699        .and_then(|verdict| {
700            verdict.validate()?;
701            Ok(verdict)
702        });
703    match verdict {
704        Ok(verdict) => {
705            run.record(
706                RunEventKind::Child(ChildEvent::Completed {
707                    child_run_id: child.run_id(),
708                }),
709                started,
710            )?;
711            record_domain(
712                run,
713                "remediation.review.completed",
714                json!({
715                    "workflow": workflow,
716                    "step": node.id,
717                    "attempt": attempt,
718                    "verdict": match &verdict {
719                        WorkflowReviewVerdict::Approve => "approve",
720                        WorkflowReviewVerdict::Repair { .. } => "repair",
721                        WorkflowReviewVerdict::Reject { .. } => "reject",
722                    },
723                }),
724                started,
725            )?;
726            Ok(verdict)
727        }
728        Err(source) => {
729            run.record(
730                RunEventKind::Child(ChildEvent::Failed {
731                    child_run_id: child.run_id(),
732                }),
733                started,
734            )?;
735            Err(WorkflowError::Review {
736                step: node.id.clone(),
737                source,
738            })
739        }
740    }
741}
742
743fn model_prompt(input: &Value) -> String {
744    match input {
745        Value::String(prompt) => prompt.clone(),
746        Value::Object(object) => object
747            .get("input")
748            .and_then(Value::as_str)
749            .map_or_else(|| input.to_string(), ToOwned::to_owned),
750        _ => input.to_string(),
751    }
752}
753
754fn model_visible_candidate(candidate: &Value) -> Value {
755    candidate
756        .as_object()
757        .and_then(|object| object.get("input"))
758        .and_then(Value::as_str)
759        .map_or_else(|| candidate.clone(), |text| Value::String(text.to_owned()))
760}
761
762fn validate_feedback(feedback: &Value) -> Result<(), WorkflowReviewError> {
763    if serde_json::to_vec(feedback).is_ok_and(|encoded| encoded.len() <= MAX_REVIEW_FEEDBACK_BYTES)
764    {
765        Ok(())
766    } else {
767        Err(WorkflowReviewError::FeedbackTooLarge)
768    }
769}
770
771fn validate_reason(reason: &str) -> Result<(), WorkflowReviewError> {
772    if reason.trim().is_empty() || reason.len() > MAX_REVIEW_REASON_BYTES {
773        Err(WorkflowReviewError::InvalidRejectionReason)
774    } else {
775        Ok(())
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use std::sync::{
782        Arc, Mutex,
783        atomic::{AtomicBool, AtomicUsize, Ordering},
784    };
785
786    use futures_executor::block_on;
787    use runifold_core::{
788        Budget, BudgetTracker, CapabilitySet, Checkpoint, CheckpointError, CheckpointErrorKind,
789        CheckpointId, CheckpointStore, InMemoryCheckpointStore, RunContext,
790    };
791    use serde_json::{Value, json};
792
793    use super::{
794        WorkflowRemediationCheckpoint, WorkflowRemediationPolicy, WorkflowRepairInput,
795        WorkflowReviewError, WorkflowReviewFuture, WorkflowReviewRequest, WorkflowReviewVerdict,
796        WorkflowReviewer,
797    };
798    use crate::{
799        Workflow, WorkflowCheckpoint, WorkflowCheckpointPhase, WorkflowCheckpointRevision,
800        WorkflowError, WorkflowForkCommand, WorkflowForkPolicy, WorkflowResumePolicy, WorkflowStep,
801        WorkflowStepFuture,
802    };
803
804    struct CountingGenerator {
805        calls: Arc<AtomicUsize>,
806        inputs: Arc<Mutex<Vec<Value>>>,
807    }
808
809    impl WorkflowStep for CountingGenerator {
810        fn execute<'a>(&'a self, input: Value, _run: &'a RunContext) -> WorkflowStepFuture<'a> {
811            let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
812            self.inputs
813                .lock()
814                .unwrap_or_else(std::sync::PoisonError::into_inner)
815                .push(input);
816            Box::pin(async move { Ok(Value::String(format!("candidate-{attempt}"))) })
817        }
818    }
819
820    struct RepairOnceReviewer {
821        calls: Arc<AtomicUsize>,
822        requests: Arc<Mutex<Vec<WorkflowReviewRequest>>>,
823    }
824
825    impl WorkflowReviewer for RepairOnceReviewer {
826        fn review<'a>(
827            &'a self,
828            request: WorkflowReviewRequest,
829            _run: &'a RunContext,
830        ) -> WorkflowReviewFuture<'a> {
831            self.calls.fetch_add(1, Ordering::SeqCst);
832            let attempt = request.attempt;
833            self.requests
834                .lock()
835                .unwrap_or_else(std::sync::PoisonError::into_inner)
836                .push(request);
837            Box::pin(async move {
838                if attempt == 1 {
839                    WorkflowReviewVerdict::repair(json!({
840                        "code": "unsafe_claim",
841                        "instruction": "remove the unsupported guarantee",
842                    }))
843                } else {
844                    Ok(WorkflowReviewVerdict::approve())
845                }
846            })
847        }
848    }
849
850    struct ApproveReviewer {
851        calls: Arc<AtomicUsize>,
852    }
853
854    impl WorkflowReviewer for ApproveReviewer {
855        fn review<'a>(
856            &'a self,
857            _request: WorkflowReviewRequest,
858            _run: &'a RunContext,
859        ) -> WorkflowReviewFuture<'a> {
860            self.calls.fetch_add(1, Ordering::SeqCst);
861            Box::pin(async { Ok(WorkflowReviewVerdict::approve()) })
862        }
863    }
864
865    struct AlwaysRepairReviewer {
866        calls: Arc<AtomicUsize>,
867    }
868
869    impl WorkflowReviewer for AlwaysRepairReviewer {
870        fn review<'a>(
871            &'a self,
872            request: WorkflowReviewRequest,
873            _run: &'a RunContext,
874        ) -> WorkflowReviewFuture<'a> {
875            self.calls.fetch_add(1, Ordering::SeqCst);
876            Box::pin(
877                async move { WorkflowReviewVerdict::repair(json!({"attempt": request.attempt})) },
878            )
879        }
880    }
881
882    struct RejectReviewer;
883
884    impl WorkflowReviewer for RejectReviewer {
885        fn review<'a>(
886            &'a self,
887            _request: WorkflowReviewRequest,
888            _run: &'a RunContext,
889        ) -> WorkflowReviewFuture<'a> {
890            Box::pin(async { WorkflowReviewVerdict::reject("policy denied the candidate") })
891        }
892    }
893
894    struct FailRevisionOnceStore {
895        inner: InMemoryCheckpointStore,
896        revision: u64,
897        failed: AtomicBool,
898    }
899
900    impl FailRevisionOnceStore {
901        fn new(revision: u64) -> Self {
902            Self {
903                inner: InMemoryCheckpointStore::new(),
904                revision,
905                failed: AtomicBool::new(false),
906            }
907        }
908    }
909
910    impl CheckpointStore for FailRevisionOnceStore {
911        fn load(&self, id: CheckpointId) -> Result<Checkpoint, CheckpointError> {
912            self.inner.load(id)
913        }
914
915        fn compare_and_swap(
916            &self,
917            checkpoint: &Checkpoint,
918            expected_revision: Option<u64>,
919        ) -> Result<(), CheckpointError> {
920            if checkpoint.revision == self.revision && !self.failed.swap(true, Ordering::SeqCst) {
921                return Err(CheckpointError::new(
922                    CheckpointErrorKind::Storage,
923                    "injected remediation checkpoint interruption",
924                ));
925            }
926            self.inner.compare_and_swap(checkpoint, expected_revision)
927        }
928    }
929
930    #[test]
931    fn repairable_step_approves_the_first_candidate() {
932        let generator_calls = Arc::new(AtomicUsize::new(0));
933        let reviewer_calls = Arc::new(AtomicUsize::new(0));
934        let workflow = workflow(
935            generator_calls.clone(),
936            Arc::new(Mutex::new(Vec::new())),
937            ApproveReviewer {
938                calls: reviewer_calls.clone(),
939            },
940            WorkflowRemediationPolicy::new(2),
941        );
942        let run = root_run();
943
944        let outcome = block_on(workflow.run("write a claim", &run)).unwrap();
945
946        assert_eq!(outcome.output, json!("candidate-1"));
947        assert_eq!(generator_calls.load(Ordering::SeqCst), 1);
948        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 1);
949    }
950
951    #[test]
952    fn repair_feedback_is_injected_and_the_new_candidate_is_reviewed() {
953        let generator_calls = Arc::new(AtomicUsize::new(0));
954        let reviewer_calls = Arc::new(AtomicUsize::new(0));
955        let inputs = Arc::new(Mutex::new(Vec::new()));
956        let requests = Arc::new(Mutex::new(Vec::new()));
957        let workflow = workflow(
958            generator_calls.clone(),
959            inputs.clone(),
960            RepairOnceReviewer {
961                calls: reviewer_calls.clone(),
962                requests: requests.clone(),
963            },
964            WorkflowRemediationPolicy::new(2),
965        );
966        let run = root_run();
967
968        let outcome = block_on(workflow.run("write a claim", &run)).unwrap();
969
970        assert_eq!(outcome.output, json!("candidate-2"));
971        assert_eq!(generator_calls.load(Ordering::SeqCst), 2);
972        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 2);
973        let inputs = inputs
974            .lock()
975            .unwrap_or_else(std::sync::PoisonError::into_inner);
976        assert_eq!(inputs[0], json!("write a claim"));
977        let repair: WorkflowRepairInput = serde_json::from_value(inputs[1].clone()).unwrap();
978        assert_eq!(repair.attempt, 2);
979        assert_eq!(repair.original_input, json!("write a claim"));
980        assert_eq!(repair.previous_candidate, json!("candidate-1"));
981        assert_eq!(repair.feedback["code"], "unsafe_claim");
982        assert!(repair.input.contains("runifold_workflow_repair"));
983        let requests = requests
984            .lock()
985            .unwrap_or_else(std::sync::PoisonError::into_inner);
986        assert_eq!(requests[0].attempt, 1);
987        assert_eq!(requests[1].attempt, 2);
988    }
989
990    #[test]
991    fn exhausted_remediation_is_durable_and_does_not_run_again_on_resume() {
992        let generator_calls = Arc::new(AtomicUsize::new(0));
993        let reviewer_calls = Arc::new(AtomicUsize::new(0));
994        let workflow = workflow(
995            generator_calls.clone(),
996            Arc::new(Mutex::new(Vec::new())),
997            AlwaysRepairReviewer {
998                calls: reviewer_calls.clone(),
999            },
1000            WorkflowRemediationPolicy::new(1),
1001        );
1002        let store = Arc::new(InMemoryCheckpointStore::new());
1003        let checkpoint = WorkflowCheckpoint::new(store);
1004        let run = root_run();
1005
1006        let error =
1007            block_on(workflow.run_checkpointed("write a claim", &run, &checkpoint)).unwrap_err();
1008
1009        assert!(matches!(
1010            error,
1011            WorkflowError::RemediationExhausted { attempts: 2, .. }
1012        ));
1013        let (_, state) = checkpoint.load().unwrap();
1014        assert!(matches!(
1015            state.phase,
1016            WorkflowCheckpointPhase::Remediating {
1017                attempt: 2,
1018                checkpoint: WorkflowRemediationCheckpoint::Exhausted { .. },
1019                ..
1020            }
1021        ));
1022        let resumed =
1023            block_on(workflow.resume(&checkpoint, &run, WorkflowResumePolicy::RejectAmbiguous))
1024                .unwrap_err();
1025        assert!(matches!(
1026            resumed,
1027            WorkflowError::RemediationExhausted { attempts: 2, .. }
1028        ));
1029        assert_eq!(generator_calls.load(Ordering::SeqCst), 2);
1030        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 2);
1031    }
1032
1033    #[test]
1034    fn durable_review_ready_resume_does_not_repeat_generation() {
1035        let generator_calls = Arc::new(AtomicUsize::new(0));
1036        let reviewer_calls = Arc::new(AtomicUsize::new(0));
1037        let workflow = workflow(
1038            generator_calls.clone(),
1039            Arc::new(Mutex::new(Vec::new())),
1040            ApproveReviewer {
1041                calls: reviewer_calls.clone(),
1042            },
1043            WorkflowRemediationPolicy::new(1),
1044        );
1045        let store = Arc::new(FailRevisionOnceStore::new(3));
1046        let checkpoint = WorkflowCheckpoint::new(store);
1047        let run = root_run();
1048
1049        let first =
1050            block_on(workflow.run_checkpointed("write a claim", &run, &checkpoint)).unwrap_err();
1051        assert!(matches!(first, WorkflowError::Checkpoint(_)));
1052        assert_eq!(generator_calls.load(Ordering::SeqCst), 1);
1053        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 0);
1054
1055        let outcome =
1056            block_on(workflow.resume(&checkpoint, &run, WorkflowResumePolicy::RejectAmbiguous))
1057                .unwrap();
1058
1059        assert_eq!(outcome.output, json!("candidate-1"));
1060        assert_eq!(generator_calls.load(Ordering::SeqCst), 1);
1061        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 1);
1062    }
1063
1064    #[test]
1065    fn in_flight_generation_requires_explicit_retry_authority() {
1066        let generator_calls = Arc::new(AtomicUsize::new(0));
1067        let reviewer_calls = Arc::new(AtomicUsize::new(0));
1068        let workflow = workflow(
1069            generator_calls.clone(),
1070            Arc::new(Mutex::new(Vec::new())),
1071            ApproveReviewer {
1072                calls: reviewer_calls.clone(),
1073            },
1074            WorkflowRemediationPolicy::new(1),
1075        );
1076        let store = Arc::new(FailRevisionOnceStore::new(2));
1077        let checkpoint = WorkflowCheckpoint::new(store);
1078        let run = root_run();
1079
1080        let first =
1081            block_on(workflow.run_checkpointed("write a claim", &run, &checkpoint)).unwrap_err();
1082        assert!(matches!(first, WorkflowError::Checkpoint(_)));
1083        let rejected =
1084            block_on(workflow.resume(&checkpoint, &run, WorkflowResumePolicy::RejectAmbiguous))
1085                .unwrap_err();
1086        assert!(matches!(
1087            rejected,
1088            WorkflowError::AmbiguousCheckpoint { .. }
1089        ));
1090        let (envelope, _) = checkpoint.load().unwrap();
1091        let rejected_fork = WorkflowForkCommand::new(
1092            checkpoint.id(),
1093            envelope.revision,
1094            WorkflowForkPolicy::RejectAmbiguous,
1095        )
1096        .prepare_checkpoint(envelope.clone())
1097        .unwrap_err();
1098        assert_eq!(rejected_fork.kind, CheckpointErrorKind::Conflict);
1099        let retryable_fork = WorkflowForkCommand::new(
1100            checkpoint.id(),
1101            envelope.revision,
1102            WorkflowForkPolicy::RetryInterruptedStep,
1103        )
1104        .prepare_checkpoint(envelope)
1105        .unwrap();
1106        let revision = WorkflowCheckpointRevision::from_checkpoint(retryable_fork).unwrap();
1107        assert!(matches!(
1108            revision.state.phase,
1109            WorkflowCheckpointPhase::Remediating {
1110                checkpoint: WorkflowRemediationCheckpoint::GenerationReady { .. },
1111                ..
1112            }
1113        ));
1114
1115        let outcome = block_on(workflow.resume(
1116            &checkpoint,
1117            &run,
1118            WorkflowResumePolicy::RetryInterruptedStep,
1119        ))
1120        .unwrap();
1121
1122        assert_eq!(outcome.output, json!("candidate-2"));
1123        assert_eq!(generator_calls.load(Ordering::SeqCst), 2);
1124        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 1);
1125    }
1126
1127    #[test]
1128    fn in_flight_review_requires_explicit_retry_without_repeating_generation() {
1129        let generator_calls = Arc::new(AtomicUsize::new(0));
1130        let reviewer_calls = Arc::new(AtomicUsize::new(0));
1131        let workflow = workflow(
1132            generator_calls.clone(),
1133            Arc::new(Mutex::new(Vec::new())),
1134            ApproveReviewer {
1135                calls: reviewer_calls.clone(),
1136            },
1137            WorkflowRemediationPolicy::new(1),
1138        );
1139        let store = Arc::new(FailRevisionOnceStore::new(4));
1140        let checkpoint = WorkflowCheckpoint::new(store);
1141        let run = root_run();
1142
1143        let first =
1144            block_on(workflow.run_checkpointed("write a claim", &run, &checkpoint)).unwrap_err();
1145        assert!(matches!(first, WorkflowError::Checkpoint(_)));
1146        assert_eq!(generator_calls.load(Ordering::SeqCst), 1);
1147        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 1);
1148        let rejected =
1149            block_on(workflow.resume(&checkpoint, &run, WorkflowResumePolicy::RejectAmbiguous))
1150                .unwrap_err();
1151        assert!(matches!(
1152            rejected,
1153            WorkflowError::AmbiguousCheckpoint { .. }
1154        ));
1155
1156        let outcome = block_on(workflow.resume(
1157            &checkpoint,
1158            &run,
1159            WorkflowResumePolicy::RetryInterruptedStep,
1160        ))
1161        .unwrap();
1162
1163        assert_eq!(outcome.output, json!("candidate-1"));
1164        assert_eq!(generator_calls.load(Ordering::SeqCst), 1);
1165        assert_eq!(reviewer_calls.load(Ordering::SeqCst), 2);
1166    }
1167
1168    #[test]
1169    fn reviewer_rejection_is_a_stable_typed_failure() {
1170        let workflow = workflow(
1171            Arc::new(AtomicUsize::new(0)),
1172            Arc::new(Mutex::new(Vec::new())),
1173            RejectReviewer,
1174            WorkflowRemediationPolicy::new(3),
1175        );
1176
1177        let error = block_on(workflow.run("write a claim", &root_run())).unwrap_err();
1178
1179        assert!(matches!(
1180            error,
1181            WorkflowError::RemediationRejected {
1182                attempts: 1,
1183                reason,
1184                ..
1185            } if reason == "policy denied the candidate"
1186        ));
1187    }
1188
1189    #[test]
1190    fn review_payloads_are_bounded_before_checkpointing() {
1191        assert!(WorkflowReviewVerdict::reject(" ").is_err());
1192        assert!(
1193            WorkflowReviewVerdict::repair(Value::String(
1194                "x".repeat(super::MAX_REVIEW_FEEDBACK_BYTES)
1195            ))
1196            .is_err()
1197        );
1198        assert_eq!(
1199            WorkflowReviewVerdict::reject("x".repeat(super::MAX_REVIEW_REASON_BYTES + 1))
1200                .unwrap_err(),
1201            WorkflowReviewError::InvalidRejectionReason
1202        );
1203    }
1204
1205    #[test]
1206    fn model_repair_prompt_projects_agent_candidate_text_without_full_outcome() {
1207        let repair = WorkflowRepairInput::new(
1208            json!("original prompt"),
1209            json!({
1210                "input": "visible candidate",
1211                "outcome": {"provider_metadata": "not-model-visible"},
1212            }),
1213            json!({"instruction": "fix it"}),
1214            2,
1215        );
1216
1217        assert!(repair.input.contains("visible candidate"));
1218        assert!(!repair.input.contains("not-model-visible"));
1219        assert_eq!(
1220            repair.previous_candidate["outcome"]["provider_metadata"],
1221            "not-model-visible"
1222        );
1223    }
1224
1225    fn workflow<R>(
1226        generator_calls: Arc<AtomicUsize>,
1227        inputs: Arc<Mutex<Vec<Value>>>,
1228        reviewer: R,
1229        policy: WorkflowRemediationPolicy,
1230    ) -> Workflow
1231    where
1232        R: WorkflowReviewer + 'static,
1233    {
1234        Workflow::builder("reviewed-generation")
1235            .repairable_step(
1236                "draft",
1237                CountingGenerator {
1238                    calls: generator_calls,
1239                    inputs,
1240                },
1241                reviewer,
1242                policy,
1243                CapabilitySet::new(),
1244                CapabilitySet::new(),
1245            )
1246            .build()
1247            .unwrap()
1248    }
1249
1250    fn root_run() -> RunContext {
1251        RunContext::root(BudgetTracker::new(Budget::default()), CapabilitySet::new())
1252    }
1253}