Chapter 18

Make Conflicts and Retries Explicit States

A retry helps a transient failure but is dangerous for a semantic conflict and a side effect. The system must classify the failure before retrying - otherwise the retry either burns the budget or duplicates an irreversible action.

Failure · Example · Action

  • Transient - Timeout, temporary rate limit, worker crash; Bounded retry with backoff and the same idempotency key
  • Permanent input - Wrong schema, missing artifact; Stop or fix the input
  • Policy - A forbidden tool or write path; Fail closed, no model retry
  • Conflict - expectedVersion is stale; A fresh read, a semantic merge, or escalation
  • Quality - The acceptance grader didn't pass; A bounded revision loop with findings

In the companion project a transient failure is retried within maxAttempts, but BudgetExceeded stops the branch at once. A policy error and budget exhaustion aren't fixed by asking "try again."

TypeScript
          for (let attempt = 1; attempt <= task.maxAttempts; attempt += 1) {
            eventLog.append({ type: "task.started", taskId: task.id, agentId, attempt });
            const openSpan = trace.start(task.id, agentId, attempt);
            try {
              const result = await handler.run({
                runId,
                task,
                inputArtifacts: store.list(),
                attempt
              });
              budget.consume(result.usage);

              for (const draft of result.artifacts) {
                if (!task.writeSet.includes(draft.key)) {
                  throw new Error(`Task ${task.id} produced undeclared artifact ${draft.key}`);
                }
                const artifact = store.write(agentId, draft);
                eventLog.append({
                  type: "artifact.written",
                  taskId: task.id,
                  key: artifact.key,
                  version: artifact.version
                });
              }
              evidence.push(...result.evidence);
              trace.end(openSpan, "ok", result.summary);
              eventLog.append({ type: "task.completed", taskId: task.id, agentId });
              return { task };
            } catch (error) {
              const reason = errorMessage(error);
              trace.end(openSpan, "error", reason);
              const terminal = attempt === task.maxAttempts || error instanceof BudgetExceededError;
              if (terminal) {
                eventLog.append({ type: "task.failed", taskId: task.id, agentId, reason });
                return { task, reason };
              }
            }
          }
          return { task, reason: `Task ${task.id} exhausted attempts` };
        })
      );

      for (const outcome of outcomes) {
        if (outcome.reason) {
          status[outcome.task.id] = "failed";
          failureReason ??= `${outcome.task.id}: ${outcome.reason}`;
        } else {
          status[outcome.task.id] = "completed";
        }
      }
    }

    if (failureReason) {
      eventLog.append({ type: "run.failed", runId, reason: failureReason });
      return {
        runId,
        status: "failed",
        taskStatus: { ...status },
        artifacts: store.list(),

A separate discipline is side-effect idempotency: form a stable operation key from run, task, and action; record the intent before the external call; before a retry, check whether the effect was already accepted by the provider; save the provider receipt as an artifact; don't let another agent repeat the operation under a new key.

Don't hide a conflict in synthesis. If two workers propose incompatible architectures, the reducer must not mechanically glue the texts. Return the alternatives, assumptions, and evidence to whoever has the right to decide.

Failures are classified. But so the run doesn't go infinite, you need limits - on each branch and on the whole run.

Links