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