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
- Move the gate to the end of the loop_group body so no other node depends on it
- Remove or re-wire any other body nodes that are left un-depended-on so the gate is the body's only terminal sink
- If you need multiple gates in one body, split the loop_group so each gate occupies the sole-sink position in its own group
- 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
- Keep exactly one un-depended-on node at the end of every loop_group body, and make it the gate
- Never add depends_on edges pointing at a gate inside a loop_group body
- Lint workflow YAML in CI for gates inside loop bodies before running
- Split multi-gate bodies into separate loop_groups
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
- loop_group_gate_completion_not_referenced
- loop_group_nested_pause_not_escalated
- node_loop_group_until_deprecated
- Cannot execute run '${detachedPreCreatedRun.id}': it belongs
- Dry-run failed; missing stubs: ${blockingMissingStubs.join('
AI-assisted analysis of coleam00/Archon@0773b97458 (2026-09-01).
Data as JSON: /api/errors/bc7682ef7fd01dfa.
Report an issue: GitHub.