fix(cli): retry codex steer tunnel aborts

This commit is contained in:
unidesk-code-queue-runner
2026-05-23 06:28:13 +00:00
parent d0c762da06
commit 6c02cb1655
7 changed files with 196 additions and 23 deletions
+2 -1
View File
@@ -67,6 +67,7 @@ function displayCommandName(parts: string[]): string {
}
if (parts[0] === "codex" && parts[1] === "steer" && parts[2] !== undefined) {
const shown = ["codex", "steer", parts[2]];
const shownValueOptions = new Set(["--prompt-file", "--file", "--retry-attempts", "--retry-delay-ms"]);
const hasPromptFile = parts.includes("--prompt-file") || parts.includes("--file");
const hasPromptStdin = parts.includes("--prompt-stdin") || parts.includes("--stdin");
const hasHelp = parts.slice(3).some(isHelpToken);
@@ -75,7 +76,7 @@ function displayCommandName(parts: string[]): string {
const part = parts[index] ?? "";
if (!part.startsWith("--")) continue;
shown.push(part);
if (part === "--prompt-file" || part === "--file") {
if (shownValueOptions.has(part)) {
shown.push(parts[index + 1] ?? "<missing>");
index += 1;
}
+53 -7
View File
@@ -147,13 +147,58 @@ export function runCodeQueueCliSteerContract(): JsonRecord {
assertCondition(!successJson.includes("send this"), "successful steer must not echo prompt text", success);
assertCondition(!successJson.includes("promptPreview"), "successful steer must not include promptPreview", success);
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, exitCode: 1, stderrTail: "Cannot connect to the Docker daemon" })), "backend-core-unreachable", null);
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 404, body: { ok: false, error: "microservice not found: code-queue" } })), "code-queue-microservice-unregistered", 404);
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 401, body: { ok: false, error: "unauthorized" } })), "proxy-unauthorized", 401);
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 404, body: { ok: false, error: "proxy route not found", path: "/api/microservices/code-queue/proxy/api/tasks/direct_task/steer" } })), "proxy-404", 404);
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 404, body: { ok: false, error: "task not found" } })), "steer-endpoint-404", 404);
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 409, body: { ok: false, error: "task does not have an active steerable turn" } })), "upstream-runtime-rejected", 409);
assertReason(codexSteerTaskForTest("direct_task", ["p"], () => ({ ok: false, status: 504, body: { ok: false, error: "provider HTTP tunnel timed out or disconnected", stage: "http-tunnel-wait" } })), "stable-proxy-failed", 504);
assertReason(codexSteerTaskForTest("direct_task", ["p", "--no-retry"], () => ({ ok: false, exitCode: 1, stderrTail: "Cannot connect to the Docker daemon" })), "backend-core-unreachable", null);
assertReason(codexSteerTaskForTest("direct_task", ["p", "--no-retry"], () => ({ ok: false, status: 404, body: { ok: false, error: "microservice not found: code-queue" } })), "code-queue-microservice-unregistered", 404);
assertReason(codexSteerTaskForTest("direct_task", ["p", "--no-retry"], () => ({ ok: false, status: 401, body: { ok: false, error: "unauthorized" } })), "proxy-unauthorized", 401);
assertReason(codexSteerTaskForTest("direct_task", ["p", "--no-retry"], () => ({ ok: false, status: 404, body: { ok: false, error: "proxy route not found", path: "/api/microservices/code-queue/proxy/api/tasks/direct_task/steer" } })), "proxy-404", 404);
assertReason(codexSteerTaskForTest("direct_task", ["p", "--no-retry"], () => ({ ok: false, status: 404, body: { ok: false, error: "task not found" } })), "steer-endpoint-404", 404);
assertReason(codexSteerTaskForTest("direct_task", ["p", "--no-retry"], () => ({ ok: false, status: 409, body: { ok: false, error: "task does not have an active steerable turn" } })), "upstream-runtime-rejected", 409);
assertReason(codexSteerTaskForTest("direct_task", ["p", "--no-retry"], () => ({ ok: false, status: 504, body: { ok: false, error: "provider HTTP tunnel timed out or disconnected", stage: "http-tunnel-wait" } })), "stable-proxy-failed", 504);
const abortedTunnelBody = {
ok: false,
error: "provider HTTP tunnel failed",
stage: "provider-gateway-http-fetch",
providerId: "D601",
serviceId: "code-queue",
providerError: "The operation was aborted",
retryable: false,
attempts: [{ attempt: 1, ok: false, durationMs: 30003, timeoutMs: 30000, result: { ok: false, error: "The operation was aborted" } }],
};
let retryCalls = 0;
const retryThenSuccess = codexSteerTaskForTest("direct_task", ["transient correction", "--retry-delay-ms", "0"], () => {
retryCalls += 1;
if (retryCalls === 1) return { ok: false, status: 502, body: abortedTunnelBody };
return {
ok: true,
status: 200,
body: {
ok: true,
task: { id: "direct_task", status: "running", prompt: "hidden" },
queue: { activeTaskIds: ["direct_task"] },
},
};
}) as JsonRecord;
assertCondition(retryCalls === 2, "retryable 502 tunnel abort should be retried once by default", { retryCalls, retryThenSuccess });
assertCondition(nestedRecord(retryThenSuccess, ["steer"]).accepted === true, "retry success should accept steer", retryThenSuccess);
const retrySuccessAttempts = nestedRecord(retryThenSuccess, ["steer"]).attempts;
assertCondition(Array.isArray(retrySuccessAttempts) && retrySuccessAttempts.length === 2, "retry success should expose both attempts", retryThenSuccess);
assertCondition(String(JSON.stringify(retryThenSuccess)).includes("The operation was aborted"), "retry attempts should preserve aborted tunnel evidence", retryThenSuccess);
assertCondition(!String(JSON.stringify(retryThenSuccess)).includes("transient correction"), "retry success must not echo steer prompt", retryThenSuccess);
let exhaustedCalls = 0;
const exhausted = codexSteerTaskForTest("direct_task", ["final correction", "--retry-attempts", "2", "--retry-delay-ms", "0"], () => {
exhaustedCalls += 1;
return { ok: false, status: 502, body: abortedTunnelBody };
}) as JsonRecord;
assertCondition(exhaustedCalls === 2, "retryable 502 tunnel abort should honor retry-attempts", { exhaustedCalls, exhausted });
assertReason(exhausted, "stable-proxy-failed", 502);
const exhaustedDiagnostics = nestedRecord(exhausted, ["diagnostics"]);
const exhaustedAttempts = exhaustedDiagnostics.attempts;
assertCondition(Array.isArray(exhaustedAttempts) && exhaustedAttempts.length === 2, "exhausted retry diagnostics should expose attempts", exhaustedDiagnostics);
assertCondition(String(exhaustedDiagnostics.message || "").includes("The operation was aborted"), "diagnostics should include provider abort message", exhaustedDiagnostics);
assertCondition(nestedRecord(exhaustedDiagnostics, ["operatorGuidance"]).rawProxyEquivalentIsFallback === false, "raw proxy equivalent should be diagnostic, not fallback", exhaustedDiagnostics);
assertCondition(String(nestedRecord(exhausted, ["commands"]).rawProxy || "").includes("microservice proxy code-queue /api/tasks/direct_task/steer"), "failure should still expose raw proxy diagnostic command", exhausted);
return {
ok: true,
@@ -170,6 +215,7 @@ export function runCodeQueueCliSteerContract(): JsonRecord {
"non-dry-run uses stable proxy helper",
"successful steer confirms write without echoing prompt",
"steer failure classification is JSON-consumable",
"retryable tunnel aborts are retried with bounded diagnostics",
],
};
}
+135 -9
View File
@@ -33,6 +33,10 @@ const submitLockWaitMs = 60_000;
const submitLockPollMs = 250;
const submitLockStaleMs = 120_000;
const submitThrottleMs = nonNegativeIntegerEnv("UNIDESK_CODEX_SUBMIT_THROTTLE_MS", 2000);
const defaultSteerRetryAttempts = 2;
const maxSteerRetryAttempts = 3;
const defaultSteerRetryDelayMs = 750;
const maxSteerRetryDelayMs = 5_000;
interface CodexTaskOptions {
detail: boolean;
@@ -138,6 +142,8 @@ interface SubmitRoutingRecommendation {
interface CodexSteerOptions {
prompt: string;
dryRun: boolean;
retryAttempts: number;
retryDelayMs: number;
}
type CodexSteerFailureReason =
@@ -164,6 +170,19 @@ interface ClassifiedCodexSteerError {
recommendedCrossChecks: string[];
}
interface CodexSteerAttemptSummary {
attempt: number;
ok: boolean;
durationMs: number;
status: number | null;
exitCode: number | null;
reason: CodexSteerFailureReason | null;
scope: ClassifiedCodexSteerError["scope"] | null;
retryable: boolean;
message: string | null;
upstreamBodyPreview?: unknown;
}
interface CompactTaskMutationResponseOptions {
fullPrompt?: boolean;
}
@@ -497,7 +516,10 @@ function responseBody(response: Record<string, unknown> | null): Record<string,
function responseErrorMessage(response: Record<string, unknown> | null): string {
const body = responseBody(response);
const bodyError = body?.error;
if (typeof bodyError === "string" && bodyError.length > 0) return bodyError;
const providerError = body?.providerError;
if (typeof bodyError === "string" && bodyError.length > 0) {
return typeof providerError === "string" && providerError.length > 0 ? `${bodyError}: ${providerError}` : bodyError;
}
if (typeof response?.codeQueueObservationNote === "string") return response.codeQueueObservationNote;
if (typeof response?.stderrTail === "string" && response.stderrTail.length > 0) return response.stderrTail;
if (typeof response?.commandStderrTail === "string" && response.commandStderrTail.length > 0) return response.commandStderrTail;
@@ -577,6 +599,41 @@ function unwrapSteerResponse(response: unknown, targetPath: string, stableProxyP
return { ok: false, diagnostics: classifySteerFailure(response, targetPath, stableProxyPath, rawProxyEquivalent) };
}
function steerSuccessAttempt(attempt: number, durationMs: number, upstream: { ok: unknown; status: unknown }): CodexSteerAttemptSummary {
const status = typeof upstream.status === "number" && Number.isFinite(upstream.status) ? upstream.status : null;
return {
attempt,
ok: true,
durationMs,
status,
exitCode: null,
reason: null,
scope: null,
retryable: false,
message: "steer accepted by Code Queue",
};
}
function steerFailureAttempt(attempt: number, durationMs: number, diagnostics: ClassifiedCodexSteerError): CodexSteerAttemptSummary {
return {
attempt,
ok: false,
durationMs,
status: diagnostics.status,
exitCode: diagnostics.exitCode,
reason: diagnostics.reason,
scope: diagnostics.scope,
retryable: diagnostics.retryable,
message: diagnostics.message,
upstreamBodyPreview: diagnostics.upstreamBodyPreview,
};
}
function shouldRetrySteerFailure(diagnostics: ClassifiedCodexSteerError, attempt: number, maxAttempts: number): boolean {
if (attempt >= maxAttempts) return false;
return diagnostics.retryable === true && (diagnostics.reason === "stable-proxy-failed" || diagnostics.reason === "backend-core-unreachable");
}
function positiveIntegerOption(args: string[], names: string[], defaultValue: number, maxValue = Number.MAX_SAFE_INTEGER): number {
for (const name of names) {
const index = args.indexOf(name);
@@ -2705,6 +2762,8 @@ const submitPromptValueOptions = new Set([
const steerPromptValueOptions = new Set([
"--prompt-file",
"--file",
"--retry-attempts",
"--retry-delay-ms",
]);
function referenceTaskIdsFromOptions(args: string[]): string[] {
@@ -2758,12 +2817,17 @@ function parseSubmitOptions(args: string[]): CodexSubmitOptions {
function parseSteerOptions(args: string[]): CodexSteerOptions {
assertKnownOptions(args, {
flags: ["--prompt-stdin", "--stdin", "--dry-run"],
valueOptions: ["--prompt-file", "--file"],
flags: ["--prompt-stdin", "--stdin", "--dry-run", "--no-retry"],
valueOptions: ["--prompt-file", "--file", "--retry-attempts", "--retry-delay-ms"],
}, "codex steer");
const retryAttempts = hasFlag(args, "--no-retry")
? 1
: positiveIntegerOption(args, ["--retry-attempts"], defaultSteerRetryAttempts, maxSteerRetryAttempts);
return {
prompt: promptFromArgs(args, "codex steer", steerPromptValueOptions),
dryRun: hasFlag(args, "--dry-run"),
retryAttempts,
retryDelayMs: nonNegativeIntegerOption(args, ["--retry-delay-ms"], defaultSteerRetryDelayMs, maxSteerRetryDelayMs),
};
}
@@ -3887,6 +3951,13 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe
path: targetPath,
stableProxyPath,
method: "POST",
retryPolicy: {
defaultAttempts: defaultSteerRetryAttempts,
maxAttempts: options.retryAttempts,
delayMs: options.retryDelayMs,
retryableReasons: ["stable-proxy-failed", "backend-core-unreachable"],
deliveryConfirmation: "success confirms Code Queue accepted the steer request; repeated stable-proxy failures mean delivery is unconfirmed",
},
bodySummary: {
promptChars: options.prompt.length,
promptPreviewChars: steerPromptPreviewChars,
@@ -3901,22 +3972,70 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe
request,
commands: {
run: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>`,
noRetry: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path> --no-retry`,
rawProxy: rawProxyEquivalent,
},
};
}
const response = unwrapSteerResponse(fetcher(stableProxyPath, { method: "POST", body: { prompt: options.prompt } }), targetPath, stableProxyPath, rawProxyEquivalent);
if (!response.ok) {
const attempts: CodexSteerAttemptSummary[] = [];
let failedResponse: { ok: false; diagnostics: ClassifiedCodexSteerError } | null = null;
let successfulResponse: { ok: true; upstream: { ok: unknown; status: unknown }; body: Record<string, unknown> } | null = null;
for (let attempt = 1; attempt <= options.retryAttempts; attempt += 1) {
const startedAt = Date.now();
const response = unwrapSteerResponse(fetcher(stableProxyPath, { method: "POST", body: { prompt: options.prompt } }), targetPath, stableProxyPath, rawProxyEquivalent);
const durationMs = Date.now() - startedAt;
if (response.ok) {
attempts.push(steerSuccessAttempt(attempt, durationMs, response.upstream));
successfulResponse = response;
break;
}
attempts.push(steerFailureAttempt(attempt, durationMs, response.diagnostics));
failedResponse = response;
if (!shouldRetrySteerFailure(response.diagnostics, attempt, options.retryAttempts)) break;
sleepSync(options.retryDelayMs);
}
if (successfulResponse === null) {
const diagnostics = failedResponse?.diagnostics ?? classifySteerFailure(null, targetPath, stableProxyPath, rawProxyEquivalent);
const transportDeliveryUnconfirmed = diagnostics.reason === "stable-proxy-failed" || diagnostics.reason === "backend-core-unreachable";
return {
ok: false,
steer: {
accepted: false,
prompt,
attempts,
deliveryUnconfirmed: transportDeliveryUnconfirmed,
retryPolicy: {
attempted: attempts.length,
maxAttempts: options.retryAttempts,
retryDelayMs: options.retryDelayMs,
retried: attempts.length > 1,
exhausted: transportDeliveryUnconfirmed && attempts.length >= options.retryAttempts,
note: "codex steer retried only retryable stable-proxy/backend-core failures; raw microservice proxy uses the same tunnel and is diagnostic, not a lower-noise fallback.",
},
},
request,
diagnostics: response.diagnostics,
diagnostics: {
...diagnostics,
attempts,
retryPolicy: {
attempted: attempts.length,
maxAttempts: options.retryAttempts,
retryDelayMs: options.retryDelayMs,
retried: attempts.length > 1,
exhausted: transportDeliveryUnconfirmed && attempts.length >= options.retryAttempts,
},
operatorGuidance: {
rawProxyEquivalentIsFallback: false,
deliveryUnconfirmed: transportDeliveryUnconfirmed,
nextStep: transportDeliveryUnconfirmed
? "Check task liveness and retry codex steer from the main-server CLI or explicit SSH transport; do not treat a raw proxy failure as separate evidence that the task rejected the correction."
: "Inspect the non-retryable reason before resubmitting the correction.",
},
},
commands: {
dryRun: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path> --dry-run`,
retry: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>`,
noRetry: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path> --no-retry`,
rawProxy: rawProxyEquivalent,
tasks: "bun scripts/cli.ts codex tasks --view supervisor --limit 20",
health: "bun scripts/cli.ts microservice health code-queue",
@@ -3925,12 +4044,19 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe
}
return {
ok: true,
upstream: response.upstream,
upstream: successfulResponse.upstream,
steer: {
accepted: true,
taskId,
promptChars: options.prompt.length,
promptOmitted: true,
attempts,
retryPolicy: {
attempted: attempts.length,
maxAttempts: options.retryAttempts,
retryDelayMs: options.retryDelayMs,
retried: attempts.length > 1,
},
outputPolicy: {
default: "write-confirmation",
promptEchoed: false,
@@ -3938,8 +4064,8 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe
reason: "codex steer is a write operation; default output confirms delivery and provides drill-down commands without echoing prompt text or full task state.",
},
},
task: compactSubmitTaskConfirmation(response.body.task),
queue: compactSubmitQueueConfirmation(response.body.queue),
task: compactSubmitTaskConfirmation(successfulResponse.body.task),
queue: compactSubmitQueueConfirmation(successfulResponse.body.queue),
commands: {
show: `bun scripts/cli.ts codex task ${taskId}`,
detail: `bun scripts/cli.ts codex task ${taskId} --detail`,
+2 -2
View File
@@ -60,7 +60,7 @@ export function rootHelp(): unknown {
{ command: "codex read <taskId>", description: "Mark one reviewed terminal task read; never run automatically as part of listing." },
{ command: "codex dev-ready", description: "Fetch execution-container readiness, including sanitized skill injection status from /api/dev-ready." },
{ command: "codex judge <taskId> --attempt N [--dry-run] [--include-prompt]", description: "Replay one stored Code Queue attempt through the same judge context builder and MiniMax judge call path used by the live queue worker." },
{ command: "codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run]", description: "Push a corrective prompt into a running Code Queue task; real success only confirms the write and does not echo prompt text." },
{ command: "codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run] [--no-retry|--retry-attempts N]", description: "Push a corrective prompt into a running Code Queue task; retryable tunnel aborts get bounded retry diagnostics, and real success does not echo prompt text." },
{ command: "codex interrupt|cancel <taskId>", description: "Request interrupt for a running Code Queue task, or cancel a queued/retry_wait task, through the same private proxy." },
{ command: "codex (queues [--full|--all] | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>)", description: "List low-noise queue summaries by default; full queue rows require --full/--all." },
{ command: "job list [--limit N] [--include-command]", description: "List async jobs from .state/jobs with a bounded default page." },
@@ -258,7 +258,7 @@ function codexHelp(): unknown {
"bun scripts/cli.ts codex dev-ready",
"bun scripts/cli.ts codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N]",
"bun scripts/cli.ts codex judge <taskId> --attempt N [--dry-run] [--include-prompt]",
"bun scripts/cli.ts codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run]",
"bun scripts/cli.ts codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run] [--no-retry|--retry-attempts N]",
"bun scripts/cli.ts codex interrupt|cancel <taskId>",
"bun scripts/cli.ts codex queues [--full|--all] [--limit N] [--page N|--offset N] | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>",
],