Skip to main content

While Component

Repeats child components while condition remains true, persisting iteration state in SQLite for crash-resume. Core primitive for review loops, convergence workflows, polling, and iterative refinement.

API

interface WhileProps {
  /**
   * Stable identifier for resumability. Required.
   * Must be deterministic across restarts - no random IDs.
   */
  id: string

  /**
   * Condition function evaluated at start of each iteration.
   * Loop continues while this returns true.
   * Result persisted to SQLite per iteration.
   */
  condition: () => boolean | Promise<boolean>

  /**
   * Maximum iterations to prevent infinite loops.
   * @default 10
   */
  maxIterations?: number

  /**
   * Children executed each iteration.
   */
  children: ReactNode

  /**
   * Callback fired at start of each iteration.
   */
  onIteration?: (iteration: number) => void

  /**
   * Callback when loop completes.
   * Reason: 'condition' (became false) or 'max' (hit limit)
   */
  onComplete?: (iterations: number, reason: 'condition' | 'max') => void
}

export function While(props: WhileProps): JSX.Element

Usage

Retry Until Success

import { While, Phase, Step, Claude, useSmithers } from 'smithers-orchestrator'

export function FixAndRetryWorkflow() {
  // Get db at component level (hooks must be called at top level)
  const { db } = useSmithers()

  return (
    <While
      id="fix-and-retry"
      condition={async () => {
        // db is captured from closure - not calling hooks inside condition
        const result = db.state.get<{ passed: boolean }>('testResult')
        return result?.passed !== true
      }}
      maxIterations={5}
      onComplete={(iterations, reason) => {
        console.log(`Tests ${reason === 'condition' ? 'passed' : 'failed'} after ${iterations} iterations`)
      }}
    >
      <Phase name="Fix and Retry">
        <Step name="Analyze Failure">
          <Claude>Analyze test failures and identify root cause</Claude>
        </Step>
        <Step name="Apply Fix">
          <Claude>Apply targeted fix</Claude>
        </Step>
        <Step name="Run Tests">
          <Claude
            onFinished={(result) => {
              // result.output is the string output from Claude
              db.state.set('testResult', {
                passed: result.output.includes('PASS'),
                output: result.output
              })
            }}
          >
            Run test suite and report results
          </Claude>
        </Step>
      </Phase>
    </While>
  )
}

Review-Fix Loop

function ReviewFixLoop() {
  // Get db at component level (hooks must be called at top level)
  const { db } = useSmithers()

  return (
    <While
      id="review-loop"
      condition={async () => {
        // db captured from closure - no hook calls inside condition
        const review = db.state.get<{ decision: string }>('lastReview')
        return review?.decision === 'request_changes'
      }}
      maxIterations={3}
    >
      <Phase name="Review">
        <Step name="review">
          <Claude
            schema={reviewSchema}
            onFinished={(result) => {
              db.state.set('lastReview', result.structured ?? result.output)
            }}
          >
            Review PR and provide structured feedback
          </Claude>
        </Step>
      </Phase>

      <Phase name="Fix">
        <Step name="fix">
          <Claude>Address review feedback and commit changes</Claude>
        </Step>
      </Phase>
    </While>
  )
}

Polling Loop

<While
  id="wait-for-ci"
  condition={async () => {
    const status = await fetch('/api/ci-status').then(r => r.json())
    return status.state === 'pending'
  }}
  maxIterations={30}
  onIteration={(i) => console.log(`Poll attempt ${i}`)}
>
  <Step name="Wait">
    <Delay ms={10000} />
  </Step>
</While>

Props

id
string
required
Stable identifier for component identity across restarts. Used to build iteration scope IDs.Scope ID format: root.while.{id}.{iteration}Examples:
  • "fix-and-retry" - Descriptive, stable
  • "review-loop" - Indicates purpose
  • ❌ Random UUID - Breaks resumability
Database keys:
  • {scopeId}.while.{id}.iteration - Current iteration number
  • {scopeId}.while.{id}.status - pending/running/complete
condition
() => boolean | Promise<boolean>
required
Function evaluated before each iteration. Loop continues while true.Evaluation points:
  1. Start of While (iteration 0) - decides if loop starts
  2. After each iteration completes - decides if next iteration runs
Persistence: Result stored in SQLite, used for crash-resume.Task gating: Holds control-flow task during evaluation to prevent race conditions.
// Sync condition (using closure-captured variable)
condition={() => attemptCount < 3}

// Database condition (db captured from component-level useSmithers)
// ✅ CORRECT - db is captured from closure, not calling hooks in condition
const { db } = useSmithers()  // at component top-level
condition={async () => {
  const result = db.state.get('converged')
  return result !== true
}}

// ❌ WRONG - calling hooks inside condition violates Rules of Hooks
condition={async () => {
  const { db } = useSmithers()  // ERROR: hooks can't be called in callbacks
  return db.state.get('converged') !== true
}}

// Async condition (API call)
condition={async () => {
  const status = await checkBuildStatus()
  return status === 'pending'
}}
maxIterations
number
default:"10"
Maximum iterations before forcing loop exit.Prevents infinite loops from buggy condition functions.Completion reason: When max reached, onComplete called with reason=‘max’.Set to high value for long-running convergence loops:
<While id="poll" condition={polling} maxIterations={1000}>
children
ReactNode
required
Components executed each iteration under isolated scope.Scoping: Each iteration gets ExecutionBoundary with unique scopeId:
  • Iteration 0: root.while.fix-tests.0
  • Iteration 1: root.while.fix-tests.1
  • Iteration N: root.while.fix-tests.N
Phase/Step re-execution: Phases and Steps inside While can re-execute each iteration without state collision due to scoped registries.Completion signaling: Children completion triggers condition re-evaluation for next iteration.
onIteration
(iteration: number) => void
Callback fired when each iteration starts (after condition evaluates true).Use cases:
  • Logging/observability
  • Updating external state
  • Progress reporting
onIteration={(i) => {
  console.log(`Starting iteration ${i}`)
  metrics.increment('while.iteration', { id: 'fix-loop' })
}}
onComplete
(iterations: number, reason: 'condition' | 'max') => void
Callback when loop exits.Parameters:
  • iterations - Total iterations executed
  • reason - Why loop ended:
    • 'condition' - Condition returned false (success)
    • 'max' - Hit maxIterations limit (potentially incomplete)
onComplete={(iterations, reason) => {
  if (reason === 'condition') {
    console.log(`Converged after ${iterations} iterations`)
  } else {
    console.warn(`Max iterations (${iterations}) reached without convergence`)
  }
}}

Implementation Status

1

Core Implementation

While component with condition evaluation, iteration state persistence, and WhileIterationContext.
2

SQLite State

Iteration count and status persisted in state table with keys while.{id}.iteration and while.{id}.status.
3

Context Provider

useWhileIteration() hook provides iteration number and signalComplete() for child components.
4

Testing

Additional test coverage for edge cases (crash-resume, max iterations, nested While loops).

Design Rationale

State Machine

┌──────────────┐
│   PENDING    │
└──────┬───────┘
       │ executionEnabled

┌──────────────┐
│  EVALUATE    │◄─────────────────────────┐
│  condition   │                          │
└──────┬───────┘                          │
       │                                  │
       ├─── true ──►┌──────────────┐      │
       │            │   RUNNING    │      │
       │            │   children   │──────┘
       │            └──────────────┘  iteration++
       │                              (on children complete)

       └─── false ──►┌──────────────┐
                     │   COMPLETE   │
                     └──────────────┘

Also: COMPLETE if iteration >= maxIterations

Why Ralph Should Wrap While

Current Ralph behavior: Loops until no pending tasks remain. Equivalent While expression:
<While
  id="ralph"
  condition={() => db.tasks.hasPending()}
  maxIterations={Infinity}
>
  {children}
</While>
Benefits of refactor:
  1. Single loop primitive - all iteration logic in one place
  2. Testable - While easier to test in isolation
  3. Composable - Ralph becomes configuration
  4. Less code - remove duplicate iteration logic from SmithersProvider

Scoping for Re-execution

Without scoping, phases/steps can’t re-execute:
<While id="fix-tests" condition={() => !testsPass} maxIterations={5}>
  <Phase name="Attempt Fix">  {/* Same phase name each iteration */}
    <Step name="Analyze">...</Step>
    <Step name="Fix">...</Step>
  </Phase>
</While>
Problem: After iteration 0, Phase “Attempt Fix” marked complete in DB. Iteration 1 sees completed phase, skips execution. Solution: Each iteration gets isolated scope:
  • Iteration 0: root.while.fix-tests.0 → phase “Attempt Fix” in this scope
  • Iteration 1: root.while.fix-tests.1 → different scope, phase can re-execute
Separate PhaseRegistry instances per scope prevent index collision.

Plan Output

<while id="fix-and-retry" maxIterations="5" iteration="2" status="running">
  <phase name="Fix and Retry" status="active">
    <step name="Analyze Failure" status="complete">...</step>
    <step name="Apply Fix" status="running">...</step>
    <step name="Run Tests" status="pending">...</step>
  </phase>
</while>
Always shows current iteration number and status for observability.

Examples of Use Cases

Use Case 1: Convergence Loop

<While
  id="refine-implementation"
  condition={async () => {
    const { db } = useSmithers()
    const quality = await db.state.get('codeQuality')
    return quality && quality.score < 0.9
  }}
  maxIterations={10}
>
  <Phase name="Refine">
    <Step name="refine">
      <Claude>Analyze code quality metrics and apply improvements</Claude>
    </Step>
  </Phase>
  <Phase name="Measure">
    <Step name="measure">
      <Claude
        onFinished={async (result) => {
          const score = extractQualityScore(result)
          await db.state.set('codeQuality', { score })
        }}
      >
        Run static analysis and compute quality score
      </Claude>
    </Step>
  </Phase>
</While>

Use Case 2: Nested While Loops

<While id="outer" condition={() => !allFeaturesDone} maxIterations={10}>
  <Phase name="Select Feature">
    <Step name="select">
      <Claude>Pick next feature from backlog</Claude>
    </Step>
  </Phase>

  <While id="inner" condition={() => !currentFeatureDone} maxIterations={5}>
    <Phase name="Implement and Test">
      <Step name="implement-test">
        <Claude>Iteratively implement and test feature</Claude>
      </Step>
    </Phase>
  </While>

  <Phase name="Mark Complete">
    <Step name="complete">
      <Claude>Mark feature as done</Claude>
    </Step>
  </Phase>
</While>
Scoping handles nesting: root.while.outer.N.while.inner.M

Use Case 3: Timeout-Based Polling

<While
  id="wait-deployment"
  condition={async () => {
    const start = await db.state.get('deploymentStartTime')
    const elapsed = Date.now() - start
    if (elapsed > 600000) return false  // 10min timeout

    const status = await checkDeploymentStatus()
    return status !== 'complete'
  }}
  maxIterations={120}  // 120 * 5s = 10min max
>
  <Step name="Poll">
    <Delay ms={5000} />
  </Step>
</While>

Alternatives Considered

  • Loop via React state: Loses crash-resume, no plan visibility
  • Recursive Phase calls: Not supported by reconciler
  • Manual iteration with Step index: Requires user to manage state, error-prone
  • Ralph-only loops: Not flexible enough for custom conditions

Feedback

If you have feedback on this planned component, please open an issue.