Делайте конфликты и повторы явными состояниями
Retry полезен для временного сбоя, но опасен для semantic conflict и side effect. Система должна классифицировать отказ до повторения - иначе повтор либо жжёт бюджет, либо дублирует необратимое действие.
Отказ · Пример · Действие
- Transient - Timeout, временный rate limit, worker crash; Bounded retry с backoff и тем же idempotency key
- Permanent input - Неверная schema, отсутствующий artifact; Stop или исправление input
- Policy - Запрещенный tool или write path; Fail closed, без model retry
- Conflict - expectedVersion устарел; Новый read, semantic merge или escalation
- Quality - Acceptance grader не пройден; Ограниченный revision loop с findings
В companion-проекте transient failure повторяется в пределах maxAttempts, но BudgetExceeded останавливает branch сразу. Policy error и расход бюджета не исправляются просьбой "попробуй ещё раз".
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(),Отдельная дисциплина - идемпотентность side effects: сформируйте stable operation key из run, task и action; запишите intent до внешнего call; перед retry проверьте, не был ли effect уже принят provider; сохраните provider receipt как artifact; не позволяйте другому агенту повторить операцию под новым key.
Не скрывайте конфликт synthesis. Если два workers предлагают несовместимые архитектуры, reducer не должен механически склеивать тексты. Верните alternatives, assumptions и evidence тому, кто имеет право принять решение.
Отказы классифицированы. Но чтобы run не ушёл в бесконечность, нужны лимиты - на каждую ветвь и на весь run.