Skip to content

[Loom#112] workflows: add workflow-level loop_until#4

Merged
wtaisto merged 1 commit into
mainfrom
slice/issue-112
May 10, 2026
Merged

[Loom#112] workflows: add workflow-level loop_until#4
wtaisto merged 1 commit into
mainfrom
slice/issue-112

Conversation

@wtaisto

@wtaisto wtaisto commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • Problem: Loom's loom-execute-prd.yaml (Loom#115) needs a way for the whole workflow (picker → implement → done) to re-run until the picker reports an empty ready set, without hand-rolling termination state in a loop node prompt.
  • Why it matters: A workflow-level loop is the cleanest expression of "do this whole workflow until X" — it composes with the workflow: invocation node from Loom#111 to give us a small, declarative orchestrator.
  • What changed: New top-level loop_until: <expression> and max_iterations: <int> fields on workflowDefinitionSchema. When loop_until is set, executeWorkflow runs runWorkflowIteration repeatedly — fresh WorkflowRun row per iteration, same user_message, scoped nodeOutputs — until the expression evaluates true against the just-completed iteration's node outputs, or max_iterations is reached. Reuses condition-evaluator.ts unchanged.
  • What did not change (scope boundary): No new condition-evaluator features. No when: parser changes. No node-level loop semantics changed. Workflows without loop_until behave identically to before.

UX Journey

Before

loom-execute-prd would have had to encode termination by hand —
e.g. a loop: node with a custom prompt that re-implements pick + dispatch
inside a single AI session, or a chain of approval-gated nodes.

Author              Archon
──────              ──────
loom-execute-prd  ──▶ runs picker → implement → done
                     (no native way to "do this again until done")

After

Author              Archon
──────              ──────
loom-execute-prd  ──▶ runs picker → implement → done
                  ◀── *evaluates loop_until against nodeOutputs*
                  ──▶ [if false] fresh WorkflowRun, same user_message
                  ──▶ ...
                  ◀── *condition true* — terminates cleanly
                  ◀── *or max_iterations exhausted* — fails

Architecture Diagram

Before

schemas/workflow.ts ── workflowDefinitionSchema (nodes)
                              │
                              ▼
executor.ts:executeWorkflow ── runs DAG once, returns result
                              │
                              ▼
                         dag-executor.ts

After

schemas/workflow.ts ── workflowDefinitionSchema [~]
                       (+ loop_until, max_iterations)
                              │
                              ▼
executor.ts:executeWorkflow [~] ─── if loop_until ──▶ loop:
                              │                       runWorkflowIteration
                              │                       evaluateCondition  ===▶ condition-evaluator.ts
                              │                       (re-runs if false)
                              │
                              ▼ else (no loop_until)
                       runWorkflowIteration  ── (existing single-run body)
                              │
                              ▼
                         dag-executor.ts

Connection inventory:

From To Status Notes
executor.ts condition-evaluator.ts new Workflow-level loop_until evaluation
executor.ts store.getCompletedDagNodeOutputs modified Now also called from the loop wrapper to fetch the just-completed iteration's outputs
executor.ts dag-executor.ts unchanged Per-iteration body is unchanged
schemas/workflow.ts (caller schema validators) unchanged New fields are optional

Label Snapshot

  • Risk: risk: low
  • Size: size: S
  • Scope: workflows
  • Module: workflows:executor

Change Metadata

  • Change type: feature
  • Primary scope: workflows

Linked Issue

Validation Evidence (required)

bun --filter @archon/workflows type-check   # 0 errors
bun --filter @archon/workflows test         # 41 + 18 + 6 + 7 + 9 = 81 pass, 0 fail
bun x prettier --check <changed files>      # clean
  • Evidence provided: 9 new tests in executor-loop-until.test.ts covering termination-on-true, max_iterations cap, default-to-20, fresh-run isolation, condition-evaluator reuse, unparseable-expression fail-fast, pause short-circuit, failure short-circuit, and parity (no-op when loop_until unset). All 31 existing executor.test.ts tests still pass — no regressions.
  • Skipped: bun run validate at repo root (not run; verified the workflows package end-to-end).

Security Impact (required)

  • New permissions/capabilities? No
  • New external network calls? No
  • Secrets/tokens handling changed? No
  • File system access scope changed? No

Compatibility / Migration

  • Backward compatible? Yes — both new schema fields are optional; absent loop_until preserves the prior single-run behavior exactly.
  • Config/env changes? No
  • Database migration needed? No

Human Verification (required)

  • Verified scenarios:
    • Termination on loop_until true after 2 iterations (test).
    • max_iterations: 3 cap fails the workflow with a descriptive error (test).
    • Default max_iterations is 20 (test runs 20 iterations on an unsatisfiable expression).
    • Each iteration is a fresh createWorkflowRun call with monotonically-increasing run ids (test).
    • Picker-shaped JSON outputs ({ empty, count, next }) feed condition-evaluator's dot notation (test).
    • Unparseable loop_until halts after the first iteration with unparseable in the error message (test).
    • Pause and failure during an iteration short-circuit the loop (tests).
  • Edge cases checked: max_iterations omitted (default 20); loop_until set with no nodes that match the expression's referenced node id (resolves to empty string, evaluates false, loops to cap).
  • What was not verified: end-to-end loom-execute-prd.yaml flow — that's Loom#115 and depends on this PR landing first.

Side Effects / Blast Radius (required)

  • Affected subsystems/workflows: executor.ts is the single entry to all workflow runs. Workflows that do not declare loop_until route through the same runWorkflowIteration body as before (a thin wrapper, not a refactor of the body itself).
  • Potential unintended effects: if a workflow author sets loop_until to an expression that always evaluates false (e.g. references a missing node), the workflow will burn max_iterations runs before failing. Mitigated by max_iterations defaulting to 20 (bounded blast).
  • Guardrails/monitoring: each iteration logs workflow.loop_until_iteration_continuing; satisfaction logs workflow.loop_until_satisfied; cap logs workflow.loop_until_max_iterations_exceeded. All include workflow name and iteration count.

Rollback Plan (required)

  • Fast rollback: revert this commit. Workflows that have started declaring loop_until will fail Zod validation cleanly (unknown field) once reverted, but no in-flight runs are affected mid-execution.
  • Feature flags: none — opt-in via the optional schema fields.
  • Observable failure symptoms: workflow.loop_until_unparseable or workflow.loop_until_max_iterations_exceeded log lines; user-visible error message with loop_until substring.

Risks and Mitigations

  • Risk: Workflow author writes loop_until referencing a node id that does not exist in nodes:.
    • Mitigation: condition-evaluator returns empty string for unknown refs; expression evaluates false; loop hits max_iterations cap and fails with a clear message naming the expression. Future improvement (out of scope here): static validation at workflow load time that $<id>.output references resolve to declared nodes.

🤖 Generated with Claude Code

When a workflow declares `loop_until: <expression>`, executeWorkflow now
re-runs the entire workflow as a fresh WorkflowRun (same user_message,
scoped nodeOutputs) until the expression evaluates true against the
just-completed iteration's node outputs, or max_iterations is reached.
Pause and failure short-circuit the loop.

This is the second of two archon primitives the Loom coleam00#110 PRD needs:
the picker -> implement -> done cycle in loom-execute-prd.yaml leans
on it for clean self-termination once no slices remain.

Reuses condition-evaluator.ts unchanged (Loom coleam00#112 acceptance).
Existing executeWorkflow body is factored out into runWorkflowIteration;
behavior is unchanged when loop_until is unset.

Refs Loom#112 (slice 2 of Loom#110)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wtaisto wtaisto merged commit 5dd4247 into main May 10, 2026
3 checks passed
@wtaisto wtaisto deleted the slice/issue-112 branch May 10, 2026 03:11
wtaisto added a commit that referenced this pull request May 23, 2026
When a workflow declares `loop_until: <expression>`, executeWorkflow now
re-runs the entire workflow as a fresh WorkflowRun (same user_message,
scoped nodeOutputs) until the expression evaluates true against the
just-completed iteration's node outputs, or max_iterations is reached.
Pause and failure short-circuit the loop.

This is the second of two archon primitives the Loom coleam00#110 PRD needs:
the picker -> implement -> done cycle in loom-execute-prd.yaml leans
on it for clean self-termination once no slices remain.

Reuses condition-evaluator.ts unchanged (Loom coleam00#112 acceptance).
Existing executeWorkflow body is factored out into runWorkflowIteration;
behavior is unchanged when loop_until is unset.

Refs Loom#112 (slice 2 of Loom#110)

Co-authored-by: Tai <tai@claricode.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants