coleam00/Archon · error

workflow.compose_fan_out_shared_checkout_collision

workflow.compose_fan_out_shared_checkout_collision

Error message

composed fan-out node '${node.id}': up to ${String(plannedConcurrency)} instances of '${node.include}' would run at once in this run's checkout, and that block does not declare `mutates_checkout: false`. Concurrent runs on one checkout take a path-exclusive lock, so all but the first would cancel themselves — and a lock-cancelled instance is not recoverable by resume (#2180). Choose one: add `mutates_checkout: false` to '${node.include}' if it only reads the repo; or set `fan_out.max_parallel: 1` on '${node.id}' to run the instances one at a time.

What it means

Refused at planning time: a composed fan-out would run multiple instances of an `include:` block that can mutate the run's shared checkout and does not declare `mutates_checkout: false`. Concurrent instances take a path-exclusive lock, so all but the first would self-cancel, and lock-cancelled instances are not recoverable by resume (#2180).

Source

Thrown at packages/workflows/src/dag-executor.ts:9060

      'items' in currentItems
        ? buildInstanceSnapshots(currentItems.items, {}, fanOut.as)
        : [...persistedSnapshots];
  }
  const snapshots = persistedSnapshots ?? computedSnapshots;

  // Concurrent instances share the parent checkout. Use the authoritative persisted
  // width on resume so a changed producer cannot bypass the original safety preflight.
  const plannedConcurrency = Math.min(fanOut.max_parallel, snapshots.length);
  if (plannedConcurrency > 1 && resolved.definition.mutates_checkout !== false) {
    const msg =
      `composed fan-out node '${node.id}': up to ${String(plannedConcurrency)} instances of ` +
      `'${node.include}' would run at once in this run's checkout, and that block does not ` +
      'declare `mutates_checkout: false`. Concurrent runs on one checkout take a ' +
      'path-exclusive lock, so all but the first would cancel themselves — and a ' +
      'lock-cancelled instance is not recoverable by resume (#2180). Choose one: add ' +
      `\`mutates_checkout: false\` to '${node.include}' if it only reads the repo; or set ` +
      `\`fan_out.max_parallel: 1\` on '${node.id}' to run the instances one at a time.`;
    getLog().warn(
      { parentRunId: parentRun.id, nodeId: node.id, include: node.include, plannedConcurrency },
      'workflow.compose_fan_out_shared_checkout_collision'
    );
    await notify(`❌ **Composed fan-out blocked** (node \`${node.id}\`): ${msg}`);
    return failResult(msg);
  }

  if (persistedSnapshots === undefined) {
    try {
      await deps.store.persistWorkflowEvent({
        workflow_run_id: parentRun.id,
        event_type: 'fan_out_instances',
        step_name: fanOutScopeName,
        data: { instances: computedSnapshots },
      });
    } catch (err) {
      const msg =
        `composed fan-out node '${node.id}' could not persist its item snapshot before ` +

View on GitHub (pinned to 0773b97458)

Solutions

  1. Add `mutates_checkout: false` to the included block if it only reads the repo
  2. Set `fan_out.max_parallel: 1` on the node to run instances one at a time

Example fix

# before
- id: loop
  include: transform
  fan_out: { over: items }
# after
- id: loop
  include: transform   # block frontmatter: mutates_checkout: false (read-only)
  fan_out: { over: items }
# or
  fan_out: { over: items, max_parallel: 1 }
Defensive patterns

Strategy: validation

Validate before calling

// Pre-flight composed fan-out against checkout mutation
const block = await composer.resolveBlock(node.include);
const parallel = node.fan_out?.max_parallel ?? Infinity;
if (parallel > 1 && block.frontmatter.mutates_checkout !== false) {
  throw new Error(`${node.include} may mutate the checkout; declare mutates_checkout: false or set max_parallel: 1`);
}

Type guard

function isComposedFanOutSafe(node: { fan_out?: { max_parallel?: number } }, block: { frontmatter: { mutates_checkout?: boolean } }): boolean {
  return (node.fan_out?.max_parallel ?? 2) <= 1 || block.frontmatter.mutates_checkout === false;
}

Prevention

When it happens

Trigger: Composed fan-out node where plannedConcurrency > 1, no `isolation: worktree` alternative applies, and the included block lacks `mutates_checkout: false`.

Common situations: Fan-out of a block that writes files or runs git commands; block authored as read-only later gaining writes; forgetting max_parallel: 1 for shared-checkout blocks.

Related errors


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