fix(code-queue): harden active state recovery
This commit is contained in:
@@ -698,6 +698,7 @@ function normalizeTask(value: unknown): QueueTask {
|
||||
nextPrompt: typeof record.nextPrompt === "string" ? record.nextPrompt : null,
|
||||
nextMode: record.nextMode === "initial" || record.nextMode === "retry" ? record.nextMode : null,
|
||||
};
|
||||
if (task.status !== "running" && task.status !== "judging") task.activeTurnId = null;
|
||||
task.stepCount = numberField(record.stepCount ?? record.llmStepCount, task.output.length);
|
||||
task.llmStepCount = task.stepCount;
|
||||
task.outputMaxSeq = Math.max(outputMaxSeq(task), numberField(record.outputMaxSeq, 0));
|
||||
@@ -1254,7 +1255,7 @@ async function queueSummary(tasks?: QueueTask[], queueRecords?: QueueRecord[]):
|
||||
memo[task.status] = (memo[task.status] ?? 0) + 1;
|
||||
return memo;
|
||||
}, {});
|
||||
const orphanedActiveTaskIds = taskRows.filter((task) => task.status === "running" || task.status === "judging").map((task) => task.id).sort();
|
||||
const databaseActiveTaskIds = taskRows.filter((task) => task.status === "running" || task.status === "judging").map((task) => task.id).sort();
|
||||
const queueList = Array.from(summaries.values()).sort((left, right) => String(left.id).localeCompare(String(right.id)));
|
||||
return {
|
||||
total: taskRows.length,
|
||||
@@ -1267,8 +1268,11 @@ async function queueSummary(tasks?: QueueTask[], queueRecords?: QueueRecord[]):
|
||||
activeRunSlotWaiters: [],
|
||||
activeTaskIds: [],
|
||||
activeTaskId: null,
|
||||
orphanedActiveTaskIds,
|
||||
orphanedActiveTaskCount: orphanedActiveTaskIds.length,
|
||||
databaseActiveTaskIds,
|
||||
databaseActiveTaskCount: databaseActiveTaskIds.length,
|
||||
executionStateSource: "postgres-control-plane",
|
||||
orphanedActiveTaskIds: [],
|
||||
orphanedActiveTaskCount: 0,
|
||||
processing: false,
|
||||
counts,
|
||||
unreadTerminal: taskRows.reduce((total, task) => total + (terminalTaskUnread(task) ? 1 : 0), 0),
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
CgroupMemoryUsage,
|
||||
CodexEventSummary,
|
||||
CodexRunResult,
|
||||
JudgeFailureDetails,
|
||||
JudgeProbeCase,
|
||||
JudgeResult,
|
||||
JsonValue,
|
||||
@@ -388,6 +389,7 @@ function readConfig(): RuntimeConfig {
|
||||
databaseUrl: envRequiredString("DATABASE_URL"),
|
||||
databasePoolMax: Math.max(1, Math.min(8, envNumber("CODE_QUEUE_DATABASE_POOL_MAX", envNumber("DATABASE_POOL_MAX", 2)))),
|
||||
databaseFlushIntervalMs: Math.max(100, Math.min(10_000, envNumber("CODE_QUEUE_DATABASE_FLUSH_INTERVAL_MS", 1000))),
|
||||
databaseClaimTimeoutMs: Math.max(1000, Math.min(60_000, envNumber("CODE_QUEUE_DATABASE_CLAIM_TIMEOUT_MS", 15_000))),
|
||||
oaEventFlowBaseUrl: envString("OA_EVENT_FLOW_BASE_URL", "http://oa-event-flow:4255").replace(/\/+$/u, ""),
|
||||
notifyClaudeQqEnabled: envBool("CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED", false),
|
||||
notifyClaudeQqBaseUrl: envString("CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL", "http://claudeqq.unidesk.svc.cluster.local:3290").replace(/\/+$/u, ""),
|
||||
@@ -928,6 +930,10 @@ function normalizeTaskMetric(value: unknown): number | null {
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : null;
|
||||
}
|
||||
|
||||
function taskCanHaveActiveTurn(status: TaskStatus): boolean {
|
||||
return status === "running" || status === "judging";
|
||||
}
|
||||
|
||||
function normalizeTask(task: QueueTask): QueueTask {
|
||||
task.queueId = safeQueueId(task.queueId);
|
||||
task.queueEnteredAt = taskQueueEnteredAt(task);
|
||||
@@ -940,6 +946,7 @@ function normalizeTask(task: QueueTask): QueueTask {
|
||||
task.readAt = null;
|
||||
}
|
||||
task.activeTurnId ??= null;
|
||||
if (!taskCanHaveActiveTurn(task.status)) task.activeTurnId = null;
|
||||
task.providerId = normalizeTaskProviderId(task.providerId);
|
||||
task.model ||= config.defaultModel;
|
||||
task.executionMode = normalizeCodeExecutionMode(task.executionMode);
|
||||
@@ -1525,7 +1532,15 @@ async function deleteTaskFromDatabase(taskId: string): Promise<void> {
|
||||
|
||||
async function claimTaskInDatabase(task: QueueTask, expectedQueueId: string): Promise<boolean> {
|
||||
if (!databaseReady) return true;
|
||||
const claimed = await sql.begin(async (client) => await upsertTaskToDatabase(client, task, { claimQueueId: expectedQueueId }));
|
||||
const claimed = await withTimeout(
|
||||
sql.begin(async (client) => {
|
||||
const timeout = `${config.databaseClaimTimeoutMs}ms`;
|
||||
await client`SELECT set_config('statement_timeout', ${timeout}, true), set_config('lock_timeout', ${timeout}, true)`;
|
||||
return await upsertTaskToDatabase(client, task, { claimQueueId: expectedQueueId });
|
||||
}),
|
||||
config.databaseClaimTimeoutMs,
|
||||
`database claim timed out after ${config.databaseClaimTimeoutMs}ms`,
|
||||
);
|
||||
if (claimed) return true;
|
||||
const databaseTask = await loadTaskFromDatabase(task.id);
|
||||
if (databaseTask !== null) reconcileHotTaskFromDatabase(databaseTask);
|
||||
@@ -2068,6 +2083,14 @@ async function initDatabasePersistence(): Promise<void> {
|
||||
AND COALESCE(task_json->>'readAt', '') <> ''
|
||||
AND (task_json->>'readAt') ~ '^\\d{4}-\\d{2}-\\d{2}T'
|
||||
`;
|
||||
await sql`
|
||||
UPDATE unidesk_code_queue_tasks
|
||||
SET
|
||||
active_turn_id = NULL,
|
||||
task_json = jsonb_set(task_json, '{activeTurnId}', 'null'::jsonb, true)
|
||||
WHERE status NOT IN ('running', 'judging')
|
||||
AND active_turn_id IS NOT NULL
|
||||
`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_queues ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_workdirs ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default'`;
|
||||
}
|
||||
@@ -3183,6 +3206,63 @@ function retryBackoffMs(completedAttempts: number): number {
|
||||
return Math.min(retryBackoffMaxMs, retryBackoffBaseMs * (2 ** exponent));
|
||||
}
|
||||
|
||||
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) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackJudgeFromWatchdog(task: QueueTask, result: CodexRunResult, error: unknown, startedAt: number): JudgeResult {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const detail: JudgeFailureDetails = {
|
||||
provider: "minimax",
|
||||
stage: "unknown",
|
||||
model: config.minimaxModel,
|
||||
apiBase: config.minimaxApiBase,
|
||||
occurredAt: nowIso(),
|
||||
durationMs,
|
||||
timeoutMs: config.judgeTimeoutMs,
|
||||
timedOut: durationMs >= config.judgeTimeoutMs,
|
||||
errorName: error instanceof Error ? error.name : "Error",
|
||||
errorMessage: message,
|
||||
};
|
||||
logger("warn", "judge_watchdog_fallback", {
|
||||
taskId: task.id,
|
||||
attempt: task.currentAttempt,
|
||||
durationMs,
|
||||
timeoutMs: config.judgeTimeoutMs,
|
||||
error: safePreview(message, 1000),
|
||||
});
|
||||
return {
|
||||
decision: result.terminalStatus === "completed" ? "complete" : "retry",
|
||||
confidence: 0.3,
|
||||
reason: `MiniMax judge watchdog fallback: ${message}`,
|
||||
source: "fallback",
|
||||
failureDetails: detail,
|
||||
raw: { minimaxFailure: detail as unknown as JsonValue },
|
||||
};
|
||||
}
|
||||
|
||||
async function judgeTaskWithWatchdog(task: QueueTask, result: CodexRunResult): Promise<JudgeResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await withTimeout(
|
||||
judgeTask(task, result),
|
||||
config.judgeTimeoutMs + 5_000,
|
||||
`judge task timed out after ${config.judgeTimeoutMs + 5_000}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
return fallbackJudgeFromWatchdog(task, result, error, startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepForRetryBackoff(task: QueueTask, delayMs: number): Promise<void> {
|
||||
let remaining = delayMs;
|
||||
while (remaining > 0 && !task.cancelRequested && !shutdownRequested) {
|
||||
@@ -3198,6 +3278,18 @@ function taskHasLocalExecutionRecoveryClaim(task: QueueTask): boolean {
|
||||
return activeRunSlotReservations.has(queueIdOf(task));
|
||||
}
|
||||
|
||||
function clearInactiveActiveTurnIds(): number {
|
||||
let changed = 0;
|
||||
for (const task of state.tasks) {
|
||||
if (taskCanHaveActiveTurn(task.status) || task.activeTurnId === null) continue;
|
||||
task.activeTurnId = null;
|
||||
task.updatedAt = nowIso();
|
||||
markTaskDirty(task.id);
|
||||
changed += 1;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function queueActiveTasksForRestartRetry(reason: string, method: string, options: { onlyActiveRuns?: boolean } = {}): number {
|
||||
if (!config.schedulerEnabled || !serviceRoleAllowsScheduler(config.serviceRole)) return 0;
|
||||
let recovered = 0;
|
||||
@@ -3287,7 +3379,24 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
task.readAt = null;
|
||||
task.finishedAt = null;
|
||||
task.updatedAt = startedAt;
|
||||
if (!await claimTaskInDatabase(task, claimQueueId)) {
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = await claimTaskInDatabase(task, claimQueueId);
|
||||
} catch (error) {
|
||||
releaseRunSlot();
|
||||
task.status = "retry_wait";
|
||||
task.activeTurnId = null;
|
||||
task.finishedAt = null;
|
||||
task.readAt = null;
|
||||
task.updatedAt = nowIso();
|
||||
task.lastError = `Database claim failed before agent start: ${error instanceof Error ? error.message : String(error)}`;
|
||||
appendOutput(task, "error", `${task.lastError}\n`, "database/claim");
|
||||
persistTaskState(task);
|
||||
logger("error", "task_claim_failed", { taskId: task.id, queueId: claimQueueId, error: errorToJson(error) });
|
||||
await sleepForRetryBackoff(task, retryBackoffMs(Math.max(1, task.attempts.length)));
|
||||
return;
|
||||
}
|
||||
if (!claimed) {
|
||||
releaseRunSlot();
|
||||
return;
|
||||
}
|
||||
@@ -3310,7 +3419,7 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
persistTaskState(task);
|
||||
|
||||
if (task.cancelRequested) break;
|
||||
const judge = await judgeTask(task, result);
|
||||
const judge = await judgeTaskWithWatchdog(task, result);
|
||||
task.lastJudge = judge;
|
||||
const judgeOutput = appendOutput(task, judge.decision === "complete" ? "system" : judge.decision === "fail" ? "error" : "system", `judge=${judge.decision} confidence=${judge.confidence.toFixed(2)} source=${judge.source}: ${judge.reason}${judgeFailureDetailsForOutput(judge)}\n`, "judge");
|
||||
const latestAttempt = task.attempts.at(-1);
|
||||
@@ -4938,12 +5047,13 @@ async function startDatabaseBackedRuntime(): Promise<void> {
|
||||
logger("info", "service_started", { port: config.port, instanceId: config.instanceId, role: config.serviceRole, schedulerEnabled: config.schedulerEnabled, schedulerPollIntervalMs: config.schedulerPollIntervalMs, workdir: config.defaultWorkdir, defaultModel: config.defaultModel, judgeConfigured: config.minimaxApiKey.length > 0, storage: "postgres" });
|
||||
const devReady = collectDevReady() as Record<string, JsonValue>;
|
||||
logger(devReady.ok === true ? "info" : "warn", "dev_ready_check", devReady);
|
||||
const inactiveActiveTurnsCleared = clearInactiveActiveTurnIds();
|
||||
const startupRecovered = config.schedulerEnabled ? queueActiveTasksForRestartRetry("Service restarted while task was active", "startup") : 0;
|
||||
const startupRecoveryDirtyTaskCount = dirtyDatabaseTaskIds.size;
|
||||
if (startupRecovered > 0 || startupRecoveryDirtyTaskCount > 0) {
|
||||
logger("warn", "startup_requeued_active_tasks", { recovered: startupRecovered, dirtyTaskCount: startupRecoveryDirtyTaskCount });
|
||||
logger("warn", "startup_requeued_active_tasks", { recovered: startupRecovered, inactiveActiveTurnsCleared, dirtyTaskCount: startupRecoveryDirtyTaskCount });
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
logger("info", "startup_requeued_active_tasks_flushed", { recovered: startupRecovered, dirtyTaskCount: startupRecoveryDirtyTaskCount });
|
||||
logger("info", "startup_requeued_active_tasks_flushed", { recovered: startupRecovered, inactiveActiveTurnsCleared, dirtyTaskCount: startupRecoveryDirtyTaskCount });
|
||||
}
|
||||
persistState();
|
||||
serviceReady = true;
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface RuntimeConfig {
|
||||
databaseUrl: string;
|
||||
databasePoolMax: number;
|
||||
databaseFlushIntervalMs: number;
|
||||
databaseClaimTimeoutMs: number;
|
||||
oaEventFlowBaseUrl: string;
|
||||
notifyClaudeQqEnabled: boolean;
|
||||
notifyClaudeQqBaseUrl: string;
|
||||
|
||||
Reference in New Issue
Block a user