fix: add code queue 429 backoff diagnostics
This commit is contained in:
@@ -3435,11 +3435,94 @@ function retryBackoffMs(completedAttempts: number): number {
|
||||
return Math.min(retryBackoffMaxMs, retryBackoffBaseMs * (2 ** exponent));
|
||||
}
|
||||
|
||||
function stableRetryJitterMs(taskId: string, completedAttempts: number, jitterWindowMs: number): number {
|
||||
const boundedWindow = Math.max(0, Math.floor(jitterWindowMs));
|
||||
if (boundedWindow <= 0) return 0;
|
||||
let hash = 2166136261;
|
||||
const input = `${taskId}:${Math.max(1, Math.floor(completedAttempts))}`;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
hash ^= input.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) % (boundedWindow + 1);
|
||||
}
|
||||
|
||||
function retryBackoffWithJitterMs(task: QueueTask, completedAttempts: number, minimumDelayMs = 0): { baseDelayMs: number; jitterMs: number; delayMs: number } {
|
||||
const baseDelayMs = Math.min(retryBackoffMaxMs, Math.max(minimumDelayMs, retryBackoffMs(completedAttempts)));
|
||||
const jitterWindowMs = Math.min(30_000, Math.max(1_000, Math.floor(baseDelayMs * 0.3)));
|
||||
const jitterMs = stableRetryJitterMs(task.id, completedAttempts, jitterWindowMs);
|
||||
return {
|
||||
baseDelayMs,
|
||||
jitterMs,
|
||||
delayMs: Math.min(retryBackoffMaxMs, baseDelayMs + jitterMs),
|
||||
};
|
||||
}
|
||||
|
||||
function judgeProviderBackoffMs(task: QueueTask): number {
|
||||
const fallbackCount = Math.max(1, fallbackJudgeRetryCount(task));
|
||||
return Math.min(5 * 60_000, Math.max(30_000, retryBackoffMs(fallbackCount)));
|
||||
}
|
||||
|
||||
function latestAttemptRunnerErrorClassification(task: QueueTask): Record<string, JsonValue> | null {
|
||||
const latest = task.attempts.at(-1)?.runnerErrorClassification;
|
||||
return typeof latest === "object" && latest !== null && !Array.isArray(latest) ? latest as Record<string, JsonValue> : null;
|
||||
}
|
||||
|
||||
function latestAttemptExternalProvider429(task: QueueTask): boolean {
|
||||
return latestAttemptRunnerErrorClassification(task)?.externalProvider429 === true;
|
||||
}
|
||||
|
||||
function retryBackoffEvidence(task: QueueTask, completedAttempts: number, reason: string): JsonValue {
|
||||
const runnerClassification = latestAttemptRunnerErrorClassification(task);
|
||||
const externalProvider429 = runnerClassification?.externalProvider429 === true;
|
||||
const baseDelayMs = retryBackoffMs(completedAttempts);
|
||||
const jittered = externalProvider429
|
||||
? retryBackoffWithJitterMs(task, completedAttempts, 30_000)
|
||||
: { baseDelayMs, jitterMs: 0, delayMs: baseDelayMs };
|
||||
return {
|
||||
strategy: externalProvider429 ? "exponential-jitter" : "exponential",
|
||||
reason,
|
||||
completedAttempts,
|
||||
baseDelayMs: jittered.baseDelayMs,
|
||||
jitterMs: jittered.jitterMs,
|
||||
delayMs: jittered.delayMs,
|
||||
maxDelayMs: retryBackoffMaxMs,
|
||||
externalProvider429,
|
||||
failureKind: runnerClassification?.failureKind ?? null,
|
||||
runnerDisposition: runnerClassification?.disposition ?? null,
|
||||
commanderBenefit: "fewer failed runners and clearer retry/backoff evidence",
|
||||
};
|
||||
}
|
||||
|
||||
function judgeProviderBackoffEvidence(task: QueueTask): JsonValue {
|
||||
const fallbackCount = Math.max(1, fallbackJudgeRetryCount(task));
|
||||
const baseDelayMs = judgeProviderBackoffMs(task);
|
||||
const jitterMs = stableRetryJitterMs(task.id, fallbackCount, Math.min(30_000, Math.max(1_000, Math.floor(baseDelayMs * 0.3))));
|
||||
return {
|
||||
strategy: "exponential-jitter",
|
||||
reason: "judge-provider-transient-retry",
|
||||
completedAttempts: task.attempts.length,
|
||||
baseDelayMs,
|
||||
jitterMs,
|
||||
delayMs: Math.min(5 * 60_000, baseDelayMs + jitterMs),
|
||||
maxDelayMs: 5 * 60_000,
|
||||
externalProvider429: false,
|
||||
failureKind: "judge-provider-transient",
|
||||
runnerDisposition: "retryable-transient",
|
||||
commanderBenefit: "fewer failed runners and clearer retry/backoff evidence",
|
||||
};
|
||||
}
|
||||
|
||||
function appendRetryBackoffEvidence(task: QueueTask, evidence: JsonValue, continuation: string): void {
|
||||
const record = typeof evidence === "object" && evidence !== null && !Array.isArray(evidence) ? evidence as Record<string, JsonValue> : {};
|
||||
const delaySeconds = Math.round(Number(record.delayMs ?? 0) / 1000);
|
||||
const baseSeconds = Math.round(Number(record.baseDelayMs ?? 0) / 1000);
|
||||
const jitterSeconds = Math.round(Number(record.jitterMs ?? 0) / 1000);
|
||||
const strategy = String(record.strategy ?? "exponential");
|
||||
const external = record.externalProvider429 === true ? " externalProvider429=true" : "";
|
||||
appendOutput(task, "system", `retry backoff ${delaySeconds}s (base=${baseSeconds}s jitter=${jitterSeconds}s strategy=${strategy}${external}) before ${continuation}\n`, "queue/backoff");
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
@@ -3785,9 +3868,10 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
judgeFailRetryLimit,
|
||||
reason: safePreview(judge.reason, 500),
|
||||
});
|
||||
const delayMs = retryBackoffMs(task.attempts.length);
|
||||
appendOutput(task, "system", `retry backoff ${Math.round(delayMs / 1000)}s before appending continuation to existing session\n`, "queue");
|
||||
await sleepForRetryBackoff(task, delayMs);
|
||||
const backoff = retryBackoffEvidence(task, task.attempts.length, "judge-fail-treated-as-retry");
|
||||
appendRetryBackoffEvidence(task, backoff, "appending continuation to existing session");
|
||||
logger("info", "task_retry_backoff", { taskId: task.id, attempt: task.currentAttempt, backoff });
|
||||
await sleepForRetryBackoff(task, Number((backoff as Record<string, JsonValue>).delayMs ?? 0));
|
||||
continue;
|
||||
}
|
||||
task.status = "failed";
|
||||
@@ -3809,17 +3893,19 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
task.nextMode = "retry";
|
||||
setAttemptFeedbackPrompt(latestAttempt, task.nextPrompt, "judge-provider-transient-retry", task.attempts.length + 1);
|
||||
task.updatedAt = nowIso();
|
||||
const delayMs = judgeProviderBackoffMs(task);
|
||||
appendOutput(task, "system", `judge provider transient failure; retry backoff ${Math.round(delayMs / 1000)}s before continuing existing session\n`, "queue");
|
||||
const backoff = judgeProviderBackoffEvidence(task);
|
||||
const providerDelayMs = Number((backoff as Record<string, JsonValue>).delayMs ?? 0);
|
||||
appendRetryBackoffEvidence(task, backoff, "continuing existing session after judge provider transient failure");
|
||||
persistTaskState(task);
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
logger("warn", "task_judge_provider_transient_retry", {
|
||||
taskId: task.id,
|
||||
fallbackRetryCount: fallbackJudgeRetryCount(task),
|
||||
delayMs,
|
||||
delayMs: providerDelayMs,
|
||||
backoff,
|
||||
reason: safePreview(judge.reason, 500),
|
||||
});
|
||||
await sleepForRetryBackoff(task, delayMs);
|
||||
await sleepForRetryBackoff(task, providerDelayMs);
|
||||
continue;
|
||||
}
|
||||
if (judge.source === "fallback" && fallbackJudgeRetryCount(task) >= fallbackJudgeRetryLimit) {
|
||||
@@ -3837,9 +3923,11 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
task.updatedAt = nowIso();
|
||||
persistTaskState(task);
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
const delayMs = retryBackoffMs(task.attempts.length);
|
||||
appendOutput(task, "system", `retry backoff ${Math.round(delayMs / 1000)}s before appending continuation to existing session\n`, "queue");
|
||||
await sleepForRetryBackoff(task, delayMs);
|
||||
const externalProvider429 = latestAttemptExternalProvider429(task);
|
||||
const backoff = retryBackoffEvidence(task, task.attempts.length, externalProvider429 ? "external-provider-429" : "judge-retry");
|
||||
appendRetryBackoffEvidence(task, backoff, "appending continuation to existing session");
|
||||
logger(externalProvider429 ? "warn" : "info", "task_retry_backoff", { taskId: task.id, attempt: task.currentAttempt, backoff });
|
||||
await sleepForRetryBackoff(task, Number((backoff as Record<string, JsonValue>).delayMs ?? 0));
|
||||
}
|
||||
|
||||
if (shutdownRequested) {
|
||||
|
||||
@@ -6,6 +6,7 @@ export type RunnerFailureScope =
|
||||
| "registry"
|
||||
| "k3s"
|
||||
| "scheduler"
|
||||
| "external-provider"
|
||||
| "unknown";
|
||||
|
||||
export type RunnerFailureDisposition =
|
||||
@@ -18,15 +19,25 @@ export type RunnerFailureDisposition =
|
||||
export interface RunnerErrorClassification {
|
||||
scope: RunnerFailureScope;
|
||||
disposition: RunnerFailureDisposition;
|
||||
failureKind: string;
|
||||
retryable: boolean;
|
||||
globalBlocker: boolean;
|
||||
singlePathObservation: boolean;
|
||||
externalProvider429: boolean;
|
||||
reason: string;
|
||||
evidence: string[];
|
||||
recommendedTriageCommand: string;
|
||||
backoffHint: {
|
||||
strategy: "exponential-jitter";
|
||||
minimumDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
jitter: string;
|
||||
commanderAction: string;
|
||||
} | null;
|
||||
contract: {
|
||||
singlePathFailureIsGlobalBlocker: false;
|
||||
globalBlockerRequiresMultiSignalTriage: true;
|
||||
externalProvider429IsInfrastructureOutage: false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +106,17 @@ export function classifyRunnerError(message: string, providerId?: string | null)
|
||||
/postgres|postgresql/gu,
|
||||
/app-server closed|opencode exited|codex .*failed/gu,
|
||||
]);
|
||||
const externalProvider429Evidence = collectEvidence(normalized, [
|
||||
/429 too many requests/gu,
|
||||
/too many requests/gu,
|
||||
/\bstatus:?\s*429\b/gu,
|
||||
/\bhttp\s*429\b/gu,
|
||||
/\b429\b/gu,
|
||||
/rate[- ]?limit(?:ed|ing)?/gu,
|
||||
/quota exceeded/gu,
|
||||
/exceeded retry limit/gu,
|
||||
/tokens per min|requests per min/gu,
|
||||
]);
|
||||
const runnerLocalEvidence = collectEvidence(normalized, [
|
||||
/spawn .*enoent/gu,
|
||||
/command not found/gu,
|
||||
@@ -107,37 +129,52 @@ export function classifyRunnerError(message: string, providerId?: string | null)
|
||||
|
||||
let scope: RunnerFailureScope = "unknown";
|
||||
let disposition: RunnerFailureDisposition = "retryable-transient";
|
||||
let failureKind = "unknown-runner-failure";
|
||||
let reason = "The runner failure needs provider triage before it can be treated as a global blocker.";
|
||||
let evidence: string[] = [];
|
||||
let externalProvider429 = false;
|
||||
|
||||
if (providerOfflineEvidence.length > 0) {
|
||||
if (externalProvider429Evidence.length > 0) {
|
||||
scope = "external-provider";
|
||||
disposition = "retryable-transient";
|
||||
failureKind = "external-provider-rate-limit";
|
||||
externalProvider429 = true;
|
||||
reason = "The failure matches an external model provider rate limit/transient 429; keep the task in retry_wait with conservative jittered backoff while scheduler heartbeat remains fresh.";
|
||||
evidence = externalProvider429Evidence;
|
||||
} else if (providerOfflineEvidence.length > 0) {
|
||||
scope = "runner-local";
|
||||
disposition = "runner-local-observation-gap";
|
||||
failureKind = "provider-offline-single-path";
|
||||
reason = "A single runner-local provider offline observation is not independent evidence of a global D601/provider outage.";
|
||||
evidence = providerOfflineEvidence;
|
||||
} else if (registryEvidence.length > 0) {
|
||||
scope = "registry";
|
||||
disposition = "service-degraded";
|
||||
failureKind = "registry-path-failure";
|
||||
reason = "The failure is registry-scoped; cross-check provider gateway, SSH, k3s and scheduler before escalating globally.";
|
||||
evidence = registryEvidence;
|
||||
} else if (k3sEvidence.length > 0) {
|
||||
scope = "k3s";
|
||||
disposition = "service-degraded";
|
||||
failureKind = "k3s-path-failure";
|
||||
reason = "The failure is k3s-scoped unless provider gateway and SSH cross-checks also fail.";
|
||||
evidence = k3sEvidence;
|
||||
} else if (providerGatewayEvidence.length > 0) {
|
||||
scope = "provider-gateway";
|
||||
disposition = "provider-degraded";
|
||||
failureKind = "provider-gateway-path-failure";
|
||||
reason = "The failure points at provider-gateway/proxy transport and requires independent SSH/scheduler checks.";
|
||||
evidence = providerGatewayEvidence;
|
||||
} else if (schedulerEvidence.length > 0) {
|
||||
scope = "scheduler";
|
||||
disposition = "retryable-transient";
|
||||
failureKind = "scheduler-runner-failure";
|
||||
reason = "The failure is scheduler/runner scoped and should be retried or triaged with runtime preflight.";
|
||||
evidence = schedulerEvidence;
|
||||
} else if (runnerLocalEvidence.length > 0) {
|
||||
scope = "runner-local";
|
||||
disposition = "runner-local-observation-gap";
|
||||
failureKind = "runner-local-observation";
|
||||
reason = "The failure was observed locally by the runner and is not enough to declare provider-global outage.";
|
||||
evidence = runnerLocalEvidence;
|
||||
}
|
||||
@@ -145,15 +182,27 @@ export function classifyRunnerError(message: string, providerId?: string | null)
|
||||
return {
|
||||
scope,
|
||||
disposition,
|
||||
failureKind,
|
||||
retryable: true,
|
||||
globalBlocker: false,
|
||||
singlePathObservation: true,
|
||||
externalProvider429,
|
||||
reason,
|
||||
evidence,
|
||||
recommendedTriageCommand: providerTriageCommand(providerId, raw, scope),
|
||||
backoffHint: externalProvider429
|
||||
? {
|
||||
strategy: "exponential-jitter",
|
||||
minimumDelayMs: 30_000,
|
||||
maxDelayMs: 10 * 60 * 1000,
|
||||
jitter: "stable positive jitter keyed by task id and completed attempt count",
|
||||
commanderAction: "wait for retry_wait to return to running while heartbeat remains fresh; do not duplicate the task unless retry attempts exhaust or heartbeat/state machine stalls",
|
||||
}
|
||||
: null,
|
||||
contract: {
|
||||
singlePathFailureIsGlobalBlocker: false,
|
||||
globalBlockerRequiresMultiSignalTriage: true,
|
||||
externalProvider429IsInfrastructureOutage: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user