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("invalid workflow reviewer configuration: {0}")]
113 InvalidConfiguration(String),
114 #[error("invalid workflow review decision: {0}")]
116 InvalidDecision(String),
117 #[error("workflow review feedback exceeds the 1 MiB durable limit")]
119 FeedbackTooLarge,
120 #[error("workflow review rejection reason must contain 1..=4096 bytes")]
122 InvalidRejectionReason,
123 #[error("workflow review failed: {0}")]
125 Execution(String),
126}
127
128#[cfg(not(target_arch = "wasm32"))]
130pub type WorkflowReviewFuture<'a> =
131 Pin<Box<dyn Future<Output = Result<WorkflowReviewVerdict, WorkflowReviewError>> + Send + 'a>>;
132
133#[cfg(target_arch = "wasm32")]
135pub type WorkflowReviewFuture<'a> =
136 Pin<Box<dyn Future<Output = Result<WorkflowReviewVerdict, WorkflowReviewError>> + 'a>>;
137
138pub trait WorkflowReviewer: Send + Sync {
140 fn review<'a>(
142 &'a self,
143 request: WorkflowReviewRequest,
144 run: &'a RunContext,
145 ) -> WorkflowReviewFuture<'a>;
146}
147
148#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
154pub struct WorkflowRepairInput {
155 pub input: String,
157 pub original_input: Value,
159 pub previous_candidate: Value,
161 pub feedback: Value,
163 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#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
197#[serde(tag = "state", rename_all = "snake_case")]
198#[non_exhaustive]
199pub enum WorkflowRemediationCheckpoint {
200 GenerationReady {
202 input: Value,
204 },
205 GenerationInFlight {
207 input: Value,
209 },
210 ReviewReady {
212 candidate: Value,
214 },
215 ReviewInFlight {
217 candidate: Value,
219 },
220 Approved {
222 output: Value,
224 },
225 Rejected {
227 candidate: Value,
229 reason: String,
231 },
232 Exhausted {
234 candidate: Value,
236 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}