coleam00/Archon · warning

loop_group_gate_not_terminal_sink

loop_group_gate_not_terminal_sink

Error message

Node '${gate.id}': a gate node inside a loop_group body must be the body's sole terminal sink to pause the enclosing loop (#2707 step 3) — this gate is not, so it will not stop loop iteration. Move it to the end of the body with nothing else depending on it, and no other node left un-depended-on.

What it means

Archon's workflow loader (packages/workflows/src/loader.ts, collectGateAndLoopDeprecationWarnings during parseDagNode) emits this warning when a gate node inside a loop_group body is either depended on by another body node or is not the body's sole terminal sink. Only a gate that is the body's single sink with nothing downstream of it has defined resume semantics that pause the enclosing loop (#2707 step 3); any other placement silently fails to stop iteration. The YAML still loads — this is authoring guidance, not a schema rejection.

Source

Thrown at packages/workflows/src/loader.ts:533

    // include node depends on as "terminal", and miss an include node that is
    // itself a second, co-terminal sink.
    const bodyDependedOn = new Set(node.loop_group.nodes.flatMap(n => n.depends_on ?? []));
    const bodySinks = node.loop_group.nodes.filter(n => !bodyDependedOn.has(n.id));
    // Every gate in the body, not just the first: a body may legitimately contain
    // more than one (e.g. an "approve to start" gate followed by work followed by
    // a "review the result" gate) — only ONE can validly be the sole terminal sink,
    // but each needs its own placement/completion-reference verdict, not just the
    // first one found.
    const gatesInBody = node.loop_group.nodes.filter(n => !isIncludeDirective(n) && isGateNode(n));
    for (const gate of gatesInBody) {
      if (bodyDependedOn.has(gate.id) || bodySinks.length > 1) {
        const message =
          `Node '${gate.id}': a gate node inside a loop_group body must be the ` +
          "body's sole terminal sink to pause the enclosing loop (#2707 step 3) — this " +
          'gate is not, so it will not stop loop iteration. Move it to the end of the ' +
          'body with nothing else depending on it, and no other node left un-depended-on.';
        warnings.push(message);
        getLog().warn({ id: gate.id, warning: message }, 'loop_group_gate_not_terminal_sink');
      } else {
        // Gate is validly the sole terminal sink. Design A (#2707 step 3) is
        // deliberately unopinionated about what a decision means — the group's own
        // 'until_bash' is the completion channel, and it either reads the gate's
        // '$<gateId>.output.decision'/'.text' or it doesn't. If it doesn't, the
        // human's answer is captured (every resolution still writes node_completed)
        // but never consulted for completion — the loop just runs to max_iterations,
        // silently ignoring every response. Structural check (does the until_bash
        // string reference the gate's node id), not prose-sniffing — no judgment
        // about what the check DOES with it, only whether it looks at it at all.
        const untilBash = node.loop_group.until_bash;
        const untilBashRefsGate =
          untilBash !== undefined &&
          Array.from(untilBash.matchAll(new RegExp(OUTPUT_REF_SOURCE, 'g'))).some(
            m => m[1] === gate.id
          );
        if (!untilBashRefsGate) {
          const gateRef = `$${gate.id}.output`;

View on GitHub (pinned to 0773b97458)

Solutions

  1. Move the gate to the end of the loop_group body so no other node depends on it
  2. Remove or re-wire any other body nodes that are left un-depended-on so the gate is the body's only terminal sink
  3. If you need multiple gates in one body, split the loop_group so each gate occupies the sole-sink position in its own group
  4. If the gate is not meant to stop the loop at all, replace it with a non-gate node or move it outside the loop_group

Example fix

# before
loop_group:
  nodes:
    - id: work
      agent: ...
    - id: approve
      gate: {}
    - id: report
      depends_on: [approve]
# after
loop_group:
  nodes:
    - id: work
      agent: ...
    - id: report
      agent: ...
    - id: approve
      gate: {}   # sole terminal sink: nothing depends on it, no other sink
Defensive patterns

Strategy: validation

Validate before calling

function validateGatePlacement(group) {
  const dependedOn = new Set(group.nodes.flatMap(n => n.depends_on ?? []));
  const sinks = group.nodes.filter(n => !dependedOn.has(n.id));
  const gates = group.nodes.filter(n => n.gate !== undefined);
  return gates.every(g => !dependedOn.has(g.id) && sinks.length === 1 && sinks[0].id === g.id);
}
// call before loadWorkflowYaml; if false, restructure the body first

Type guard

function isSoleTerminalGate(node, group) {
  const dependedOn = new Set(group.nodes.flatMap(n => n.depends_on ?? []));
  const sinks = group.nodes.filter(n => !dependedOn.has(n.id));
  return 'gate' in node && !dependedOn.has(node.id) && sinks.length === 1 && sinks[0].id === node.id;
}

Prevention

When it happens

Trigger: Loading a workflow YAML where a loop_group body contains a gate node that (a) appears in another body node's depends_on list, or (b) coexists with more than one un-depended-on node in the body (multiple terminal sinks). Detected in collectGateAndLoopDeprecationWarnings when bodyDependedOn.has(gate.id) || bodySinks.length > 1.

Common situations: Authors placing a mid-body 'approve to continue' gate expecting it to halt each iteration; adding a follow-up node after the review gate so it is no longer terminal; leaving a second un-depended-on node in the body so the gate is not the sole sink; migrating from the legacy on_reject gate pattern.

Related errors


AI-assisted analysis of coleam00/Archon@0773b97458 (2026-09-01). Data as JSON: /api/errors/bc7682ef7fd01dfa. Report an issue: GitHub.