Add Code Queue resume contract
This commit is contained in:
@@ -83,6 +83,24 @@ function displayCommandName(parts: string[]): string {
|
||||
}
|
||||
return shown.join(" ");
|
||||
}
|
||||
if (parts[0] === "codex" && parts[1] === "resume" && parts[2] !== undefined) {
|
||||
const shown = ["codex", "resume", parts[2]];
|
||||
const shownValueOptions = new Set(["--prompt-file", "--file", "--resume-id", "--resumeId"]);
|
||||
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);
|
||||
if (!hasPromptFile && !hasPromptStdin && !hasHelp) shown.push("<prompt:redacted>");
|
||||
for (let index = 3; index < parts.length; index += 1) {
|
||||
const part = parts[index] ?? "";
|
||||
if (!part.startsWith("--")) continue;
|
||||
shown.push(part);
|
||||
if (shownValueOptions.has(part)) {
|
||||
shown.push(parts[index + 1] ?? "<missing>");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return shown.join(" ");
|
||||
}
|
||||
if (parts[0] === "commander" && parts[1] === "approval" && parts[2] === "request") {
|
||||
const shown: string[] = [];
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { codexResumeTaskForTest } from "./src/code-queue";
|
||||
import { findResumeTraceConfirmation, resumeDuplicateDecision, resumeTraceText } from "../src/components/microservices/code-queue/src/resume-confirmation";
|
||||
import type { QueueTask } from "../src/components/microservices/code-queue/src/types";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
function nestedRecord(value: unknown, path: string[]): JsonRecord {
|
||||
let current: unknown = value;
|
||||
for (const key of path) {
|
||||
assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current });
|
||||
current = (current as JsonRecord)[key];
|
||||
}
|
||||
assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current });
|
||||
return current as JsonRecord;
|
||||
}
|
||||
|
||||
function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } {
|
||||
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
|
||||
cwd: process.cwd(),
|
||||
input: stdin,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const stdout = String(result.stdout || "");
|
||||
let json: JsonRecord | null = null;
|
||||
try {
|
||||
json = JSON.parse(stdout) as JsonRecord;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { status: result.status, stdout, stderr: String(result.stderr || ""), json };
|
||||
}
|
||||
|
||||
function fixtureTask(): QueueTask {
|
||||
const at = "2026-05-23T00:00:00.000Z";
|
||||
return {
|
||||
id: "codex_resume_fixture",
|
||||
queueId: "default",
|
||||
queueEnteredAt: at,
|
||||
prompt: "base",
|
||||
basePrompt: "base",
|
||||
referenceTaskIds: [],
|
||||
referenceInjection: null,
|
||||
providerId: "D601",
|
||||
cwd: "/workspace/unidesk",
|
||||
model: "gpt-5.5",
|
||||
reasoningEffort: null,
|
||||
executionMode: "default",
|
||||
maxAttempts: 99,
|
||||
status: "succeeded",
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
startedAt: at,
|
||||
finishedAt: "2026-05-23T00:10:00.000Z",
|
||||
readAt: null,
|
||||
currentAttempt: 1,
|
||||
currentMode: "initial",
|
||||
codexThreadId: "thread_resume_fixture",
|
||||
activeTurnId: null,
|
||||
finalResponse: "done",
|
||||
lastError: null,
|
||||
lastJudge: null,
|
||||
judgeFailCount: 0,
|
||||
promptHistory: [],
|
||||
output: [],
|
||||
events: [],
|
||||
attempts: [],
|
||||
cancelRequested: false,
|
||||
nextPrompt: null,
|
||||
nextMode: null,
|
||||
};
|
||||
}
|
||||
|
||||
function deterministicResumeId(taskId: string, prompt: string): string {
|
||||
return `resume_${Bun.SHA256.hash(`unidesk-code-queue-resume:v1\0${taskId}\0${prompt}`, "hex").slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function assertDryRunPrompt(response: JsonRecord, expectedText: string): void {
|
||||
assertCondition(response.ok === true, "CLI dry-run should succeed", response);
|
||||
const data = nestedRecord(response.data, []);
|
||||
assertCondition(data.dryRun === true, "dry-run response should expose dryRun=true", data);
|
||||
const request = nestedRecord(response.data, ["request"]);
|
||||
assertCondition(request.method === "POST", "dry-run should expose request method", request);
|
||||
assertCondition(request.path === "/api/tasks/codex_test_task/resume", "dry-run should expose resume path", request);
|
||||
assertCondition(request.stableProxyPath === "/api/microservices/code-queue/proxy/api/tasks/codex_test_task/resume", "dry-run should expose stable proxy path", request);
|
||||
assertCondition(request.resumeId === deterministicResumeId("codex_test_task", expectedText), "dry-run should expose deterministic resumeId", request);
|
||||
const prompt = nestedRecord(response.data, ["request", "body", "prompt"]);
|
||||
assertCondition(prompt.text === expectedText, "dry-run prompt text mismatch", prompt);
|
||||
assertCondition(prompt.chars === expectedText.length, "dry-run prompt char count mismatch", prompt);
|
||||
const commands = nestedRecord(response.data, ["commands"]);
|
||||
assertCondition(String(commands.run || "").includes(`--resume-id ${String(request.resumeId)}`), "dry-run should expose same resumeId run command", commands);
|
||||
}
|
||||
|
||||
export function runCodeQueueResumeContract(): JsonRecord {
|
||||
const positional = runCli(["codex", "resume", "codex_test_task", "fix the PR conflict", "--dry-run"]);
|
||||
assertDryRunPrompt(positional.json ?? {}, "fix the PR conflict");
|
||||
assertCondition(String(positional.json?.command || "").includes("<prompt:redacted>"), "outer command should redact positional resume prompt", positional.json ?? {});
|
||||
assertCondition(!String(positional.json?.command || "").includes("fix the PR conflict"), "outer command must not echo positional resume prompt", positional.json ?? {});
|
||||
|
||||
const stdin = runCli(["codex", "resume", "codex_test_task", "--prompt-stdin", "--dry-run"], "stdin resume prompt\n");
|
||||
assertDryRunPrompt(stdin.json ?? {}, "stdin resume prompt\n");
|
||||
|
||||
const promptFile = join(tmpdir(), `unidesk-code-queue-resume-${process.pid}.txt`);
|
||||
writeFileSync(promptFile, "file resume prompt", "utf8");
|
||||
try {
|
||||
const fromFile = runCli(["codex", "resume", "codex_test_task", "--prompt-file", promptFile, "--dry-run"]);
|
||||
assertDryRunPrompt(fromFile.json ?? {}, "file resume prompt");
|
||||
} finally {
|
||||
unlinkSync(promptFile);
|
||||
}
|
||||
|
||||
const duplicateSource = runCli(["codex", "resume", "codex_test_task", "positional", "--prompt-stdin", "--dry-run"], "stdin\n");
|
||||
assertCondition(duplicateSource.status !== 0, "duplicate prompt source should fail", duplicateSource.json ?? { stdout: duplicateSource.stdout });
|
||||
assertCondition(String(nestedRecord(duplicateSource.json, ["error"]).message || "").includes("exactly one prompt source"), "duplicate prompt source error should be explicit", duplicateSource.json ?? {});
|
||||
|
||||
const help = runCli(["codex", "help"]);
|
||||
const usage = Array.isArray(nestedRecord(help.json?.data, []).usage) ? nestedRecord(help.json?.data, []).usage as unknown[] : [];
|
||||
assertCondition(usage.some((line) => String(line).includes("codex resume <taskId>")), "codex help should list resume", { usage: usage.map(String) });
|
||||
|
||||
let dryRunFetchCount = 0;
|
||||
const dryRunDirect = codexResumeTaskForTest("direct_task", ["do not send", "--dry-run"], () => {
|
||||
dryRunFetchCount += 1;
|
||||
return { ok: true, status: 200, body: { ok: true } };
|
||||
});
|
||||
assertCondition(dryRunFetchCount === 0, "dry-run must not call stable proxy helper", { dryRunFetchCount, dryRunDirect });
|
||||
|
||||
const longPrompt = `${"x".repeat(480)}-tail-secret-marker`;
|
||||
const longDryRun = codexResumeTaskForTest("direct_task", [longPrompt, "--dry-run"], () => {
|
||||
throw new Error("dry-run should not fetch");
|
||||
}) as JsonRecord;
|
||||
const longPreview = nestedRecord(longDryRun, ["request", "body", "prompt"]);
|
||||
assertCondition(longPreview.truncated === true, "long dry-run prompt should be truncated", longPreview);
|
||||
assertCondition(!String(longPreview.text || "").includes("tail-secret-marker"), "long dry-run must not leak prompt tail", longPreview);
|
||||
|
||||
let fetchPath = "";
|
||||
let fetchMethod = "";
|
||||
let fetchPrompt = "";
|
||||
let fetchResumeId = "";
|
||||
const success = codexResumeTaskForTest("direct_task", ["resume this context"], (path, init) => {
|
||||
fetchPath = path;
|
||||
fetchMethod = String(init?.method || "");
|
||||
fetchPrompt = String((init?.body as JsonRecord | undefined)?.prompt || "");
|
||||
fetchResumeId = String((init?.body as JsonRecord | undefined)?.resumeId || "");
|
||||
return {
|
||||
ok: true,
|
||||
status: 202,
|
||||
body: {
|
||||
ok: true,
|
||||
accepted: true,
|
||||
duplicateSuppressed: false,
|
||||
deliveryState: "queued_for_existing_thread",
|
||||
resumeId: fetchResumeId,
|
||||
turnId: 9,
|
||||
reuseOriginalThread: true,
|
||||
originalCodexThreadId: "thread_original",
|
||||
codexThreadId: "thread_original",
|
||||
traceConfirmation: {
|
||||
taskId: "direct_task",
|
||||
resumeId: fetchResumeId,
|
||||
found: true,
|
||||
accepted: true,
|
||||
deliveryState: "queued_for_existing_thread",
|
||||
matchCount: 1,
|
||||
trace: { seq: 9, at: "2026-05-23T00:00:09.000Z", method: "turn/resume", resumeId: fetchResumeId, promptChars: 19, promptHash: "hash", promptOmitted: true, source: "output" },
|
||||
duplicateSuppressionKey: fetchResumeId,
|
||||
promptOmitted: true,
|
||||
},
|
||||
task: { id: "direct_task", status: "queued", codexThreadId: "thread_original", currentAttempt: 1, currentMode: "initial", prompt: "hidden" },
|
||||
queue: { activeTaskIds: [], queuedTaskIds: ["direct_task"] },
|
||||
},
|
||||
};
|
||||
}) as JsonRecord;
|
||||
assertCondition(fetchPath === "/api/microservices/code-queue/proxy/api/tasks/direct_task/resume", "non-dry-run should use stable resume path", { fetchPath });
|
||||
assertCondition(fetchMethod === "POST", "non-dry-run should POST", { fetchMethod });
|
||||
assertCondition(fetchPrompt === "resume this context", "non-dry-run should send raw prompt in body", { fetchPrompt });
|
||||
assertCondition(fetchResumeId === deterministicResumeId("direct_task", "resume this context"), "non-dry-run should send deterministic resumeId", { fetchResumeId });
|
||||
assertCondition(nestedRecord(success, ["resume"]).accepted === true, "successful resume should report accepted=true", success);
|
||||
assertCondition(nestedRecord(success, ["resume"]).reusedCodexThread === true, "successful resume should report thread reuse", success);
|
||||
assertCondition(nestedRecord(success, ["resume"]).promptOmitted === true, "successful resume should mark prompt omitted", success);
|
||||
assertCondition(nestedRecord(success, ["resume"]).deliveryState === "queued_for_existing_thread", "successful resume should expose delivery state", success);
|
||||
assertCondition(!JSON.stringify(success).includes("resume this context"), "successful resume must not echo prompt text", success);
|
||||
|
||||
const explicitResumeId = "resume_manual_12345";
|
||||
const duplicateSuppressed = codexResumeTaskForTest("direct_task", ["same prompt", "--resume-id", explicitResumeId], (_path, init) => {
|
||||
assertCondition((init?.body as JsonRecord | undefined)?.resumeId === explicitResumeId, "explicit resumeId should be sent unchanged", (init?.body as JsonRecord | undefined) ?? {});
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
accepted: true,
|
||||
duplicateSuppressed: true,
|
||||
deliveryState: "duplicate_suppressed",
|
||||
resumeId: explicitResumeId,
|
||||
reuseOriginalThread: true,
|
||||
originalCodexThreadId: "thread_original",
|
||||
codexThreadId: "thread_original",
|
||||
traceConfirmation: {
|
||||
taskId: "direct_task",
|
||||
resumeId: explicitResumeId,
|
||||
found: true,
|
||||
accepted: true,
|
||||
deliveryState: "queued_for_existing_thread",
|
||||
matchCount: 1,
|
||||
trace: { seq: 11, at: "2026-05-23T00:00:11.000Z", method: "turn/resume", resumeId: explicitResumeId, promptChars: 11, promptHash: "hash2", promptOmitted: true, source: "output" },
|
||||
duplicateSuppressionKey: explicitResumeId,
|
||||
},
|
||||
task: { id: "direct_task", status: "queued", codexThreadId: "thread_original", prompt: "hidden" },
|
||||
queue: { queuedTaskIds: ["direct_task"] },
|
||||
},
|
||||
};
|
||||
}) as JsonRecord;
|
||||
assertCondition(nestedRecord(duplicateSuppressed, ["resume"]).status === "duplicate_suppressed", "duplicate resume should expose suppression status", duplicateSuppressed);
|
||||
assertCondition(nestedRecord(duplicateSuppressed, ["resume"]).duplicateSuppressed === true, "duplicate resume should expose duplicateSuppressed", duplicateSuppressed);
|
||||
|
||||
const conflictPrompt = "changed resume request requested-secret-marker";
|
||||
const conflict = codexResumeTaskForTest("direct_task", [conflictPrompt, "--resume-id", explicitResumeId], () => ({
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: {
|
||||
ok: false,
|
||||
error: "resumeId already exists with a different prompt hash",
|
||||
accepted: false,
|
||||
deliveryState: "not_accepted",
|
||||
resumeId: explicitResumeId,
|
||||
existingPromptHash: "old",
|
||||
requestedPromptHash: "new",
|
||||
traceConfirmation: {
|
||||
taskId: "direct_task",
|
||||
resumeId: explicitResumeId,
|
||||
found: true,
|
||||
accepted: true,
|
||||
deliveryState: "queued_for_existing_thread",
|
||||
matchCount: 1,
|
||||
trace: { seq: 11, at: "2026-05-23T00:00:11.000Z", method: "turn/resume", resumeId: explicitResumeId, promptChars: 11, promptHash: "old", promptOmitted: true, source: "output" },
|
||||
},
|
||||
task: { id: "direct_task", status: "queued", prompt: `${"hidden ".repeat(80)}task-secret-marker` },
|
||||
},
|
||||
})) as JsonRecord;
|
||||
assertCondition(conflict.ok === false, "resumeId conflict should fail", conflict);
|
||||
assertCondition(nestedRecord(conflict, ["resume"]).status === "not_accepted", "conflict should expose not_accepted", conflict);
|
||||
assertCondition(!JSON.stringify(conflict).includes("requested-secret-marker"), "conflict must not echo requested prompt", conflict);
|
||||
assertCondition(!JSON.stringify(conflict).includes("task-secret-marker"), "conflict must not echo full task prompt by default", conflict);
|
||||
|
||||
const runningRejection = codexResumeTaskForTest("running_task", ["use steer instead"], () => ({
|
||||
ok: false,
|
||||
status: 409,
|
||||
body: {
|
||||
ok: false,
|
||||
error: "task is active: running",
|
||||
accepted: false,
|
||||
deliveryState: "not_accepted",
|
||||
disposition: "use-steer-for-active-task",
|
||||
resumeId: deterministicResumeId("running_task", "use steer instead"),
|
||||
task: { id: "running_task", status: "running", terminalStatus: null, prompt: "hidden active task" },
|
||||
},
|
||||
})) as JsonRecord;
|
||||
assertCondition(runningRejection.ok === false, "running task resume should fail closed", runningRejection);
|
||||
assertCondition(nestedRecord(runningRejection, ["resume"]).reason === "use-steer-for-active-task", "running resume should route to steer", runningRejection);
|
||||
assertCondition(String(nestedRecord(runningRejection, ["commands"]).steer || "").includes("codex steer running_task"), "running rejection should provide steer command", runningRejection);
|
||||
|
||||
const notFound = codexResumeTaskForTest("missing_task", ["prompt"], () => ({ ok: false, status: 404, body: { ok: false, error: "task not found" } })) as JsonRecord;
|
||||
assertCondition(notFound.ok === false, "missing task resume should fail", notFound);
|
||||
assertCondition(nestedRecord(notFound, ["resume"]).status === "not_accepted", "missing task should be not accepted", notFound);
|
||||
|
||||
const task = fixtureTask();
|
||||
const resumeId = "resume_contract_12345";
|
||||
const prompt = "continue the same PR";
|
||||
task.output.push({ seq: 9, at: "2026-05-23T00:00:09.000Z", channel: "user", method: "turn/resume", itemId: resumeId, text: resumeTraceText(resumeId, prompt) });
|
||||
const confirmation = findResumeTraceConfirmation(task, resumeId);
|
||||
assertCondition(confirmation.found === true && confirmation.accepted === true, "confirmation should find resume trace by resumeId", confirmation as unknown as JsonRecord);
|
||||
assertCondition(confirmation.trace?.promptChars === prompt.length, "confirmation should expose prompt chars without prompt text", (confirmation.trace ?? {}) as unknown as JsonRecord);
|
||||
assertCondition(!JSON.stringify(confirmation).includes(prompt), "confirmation must not echo prompt text", confirmation as unknown as JsonRecord);
|
||||
const duplicate = resumeDuplicateDecision(task, resumeId, prompt);
|
||||
assertCondition(duplicate.duplicate === true && duplicate.conflict === false, "same resumeId and prompt should be duplicate-suppressed", duplicate as unknown as JsonRecord);
|
||||
const localConflict = resumeDuplicateDecision(task, resumeId, "changed prompt");
|
||||
assertCondition(localConflict.duplicate === false && localConflict.conflict === true, "same resumeId with changed prompt should conflict", localConflict as unknown as JsonRecord);
|
||||
const newlineResumeId = "resume_contract_newline";
|
||||
const newlinePrompt = "keep trailing newline\n";
|
||||
task.output.push({ seq: 10, at: "2026-05-23T00:00:10.000Z", channel: "user", method: "turn/resume", itemId: newlineResumeId, text: resumeTraceText(newlineResumeId, newlinePrompt) });
|
||||
const newlineDuplicate = resumeDuplicateDecision(task, newlineResumeId, newlinePrompt);
|
||||
assertCondition(newlineDuplicate.duplicate === true && newlineDuplicate.conflict === false, "duplicate suppression should use exact prompt hash, including trailing newline", newlineDuplicate as unknown as JsonRecord);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
checks: [
|
||||
"resume positional/stdin/file dry-runs",
|
||||
"bounded disclosure and outer command redaction",
|
||||
"non-dry-run sends resumeId and omits prompt from output",
|
||||
"terminal resume accepted with thread reuse metadata",
|
||||
"duplicate suppression and conflict behavior",
|
||||
"running task fails closed with steer command",
|
||||
"missing task fails closed",
|
||||
"local resume trace confirmation helpers",
|
||||
"exact prompt hash survives trailing newline",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
process.stdout.write(`${JSON.stringify(runCodeQueueResumeContract(), null, 2)}\n`);
|
||||
}
|
||||
@@ -370,6 +370,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(commandItem("code-queue:prompt-lint-contract", ["bun", "scripts/code-queue-prompt-lint-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:cli-steer-contract", ["bun", "scripts/code-queue-cli-steer-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:steer-confirmation-contract", ["bun", "scripts/code-queue-steer-confirmation-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:resume-contract", ["bun", "scripts/code-queue-resume-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:read-terminal-contract", ["bun", "scripts/code-queue-cli-read-terminal-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-prompt-contract", ["bun", "scripts/code-queue-cli-submit-prompt-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-execution-mode-contract", ["bun", "scripts/code-queue-submit-execution-mode-contract-test.ts"], 30_000));
|
||||
@@ -407,6 +408,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("code-queue:prompt-lint-contract", "Code Queue prompt live-authorization lint contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:cli-steer-contract", "Code Queue steer CLI contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:steer-confirmation-contract", "Code Queue steer delivery confirmation contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:resume-contract", "Code Queue resume CLI and delivery contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:read-terminal-contract", "Code Queue terminal read contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:submit-prompt-contract", "Code Queue submit prompt contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:submit-execution-mode-contract", "Code Queue submit execution-mode contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
|
||||
+240
-1
@@ -3,6 +3,7 @@ import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
import { previewJson } from "./preview";
|
||||
import { createResumeId, type ResumeDeliveryState } from "../../src/components/microservices/code-queue/src/resume-confirmation";
|
||||
import { createSteerId, type SteerDeliveryState } from "../../src/components/microservices/code-queue/src/steer-confirmation";
|
||||
import {
|
||||
codeAgentPortForModel,
|
||||
@@ -234,6 +235,14 @@ interface CodexSteerConfirmOptions {
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
interface CodexResumeOptions {
|
||||
prompt: string;
|
||||
resumeId: string | undefined;
|
||||
dryRun: boolean;
|
||||
full: boolean;
|
||||
raw: boolean;
|
||||
}
|
||||
|
||||
type CodexSteerFailureReason =
|
||||
| "backend-core-unreachable"
|
||||
| "code-queue-microservice-unregistered"
|
||||
@@ -245,6 +254,8 @@ type CodexSteerFailureReason =
|
||||
| "invalid-proxy-response";
|
||||
|
||||
type CodexSteerAcceptanceStatus = "accepted" | "not_accepted" | "accepted_response_timeout" | "unknown";
|
||||
type CodexResumeAcceptanceStatus = "queued" | "duplicate_suppressed" | "not_accepted" | "accepted_response_timeout" | "unknown";
|
||||
type CodexResumeDeliveryState = ResumeDeliveryState;
|
||||
|
||||
interface ClassifiedCodexSteerError {
|
||||
reason: CodexSteerFailureReason;
|
||||
@@ -468,6 +479,10 @@ function rawSteerConfirmationCommand(taskId: string, steerId: string): string {
|
||||
return `bun scripts/cli.ts microservice proxy code-queue ${steerConfirmationPath(taskId, steerId)} --raw`;
|
||||
}
|
||||
|
||||
function resumeCommand(taskId: string, resumeId: string): string {
|
||||
return `bun scripts/cli.ts codex resume ${taskId} --prompt-file <path> --resume-id ${resumeId}`;
|
||||
}
|
||||
|
||||
function nonNegativeIntegerEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim().length === 0) return fallback;
|
||||
@@ -2595,6 +2610,10 @@ export function codexSteerTaskForTest(taskId: string, optionArgs: string[], fetc
|
||||
return codexSteerTask(taskId, optionArgs, fetcher);
|
||||
}
|
||||
|
||||
export function codexResumeTaskForTest(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher): unknown {
|
||||
return codexResumeTask(taskId, optionArgs, fetcher);
|
||||
}
|
||||
|
||||
export function codexSteerTraceConfirmForTest(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher): unknown {
|
||||
return codexSteerTraceConfirm(taskId, optionArgs, fetcher);
|
||||
}
|
||||
@@ -4071,6 +4090,13 @@ const steerPromptValueOptions = new Set([
|
||||
"--steerId",
|
||||
]);
|
||||
|
||||
const resumePromptValueOptions = new Set([
|
||||
"--prompt-file",
|
||||
"--file",
|
||||
"--resume-id",
|
||||
"--resumeId",
|
||||
]);
|
||||
|
||||
function referenceTaskIdsFromOptions(args: string[]): string[] {
|
||||
const values = optionValues(args, ["--reference-task-id", "--reference", "--ref"]);
|
||||
const ids: string[] = [];
|
||||
@@ -4207,6 +4233,22 @@ function parseSteerConfirmOptions(args: string[]): CodexSteerConfirmOptions {
|
||||
return { steerId, raw: hasFlag(args, "--raw") };
|
||||
}
|
||||
|
||||
function parseResumeOptions(args: string[]): CodexResumeOptions {
|
||||
assertKnownOptions(args, {
|
||||
flags: ["--prompt-stdin", "--stdin", "--dry-run", "--full", "--raw"],
|
||||
valueOptions: ["--prompt-file", "--file", "--resume-id", "--resumeId"],
|
||||
}, "codex resume");
|
||||
const resumeId = optionValue(args, ["--resume-id", "--resumeId"]);
|
||||
if (resumeId !== undefined && !/^[A-Za-z0-9._:-]{8,128}$/u.test(resumeId)) throw new Error("--resume-id must be 8-128 chars using letters, numbers, dot, underscore, colon, or dash");
|
||||
return {
|
||||
prompt: promptFromArgs(args, "codex resume", resumePromptValueOptions),
|
||||
resumeId,
|
||||
dryRun: hasFlag(args, "--dry-run"),
|
||||
full: hasFlag(args, "--full") || hasFlag(args, "--raw"),
|
||||
raw: hasFlag(args, "--raw"),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePromptLintOptions(args: string[]): CodexPromptLintOptions {
|
||||
assertKnownOptions(args, {
|
||||
flags: ["--prompt-stdin", "--stdin"],
|
||||
@@ -4308,6 +4350,58 @@ function compactSteerTaskConfirmation(task: unknown, steerId: string): Record<st
|
||||
};
|
||||
}
|
||||
|
||||
function compactResumeTraceConfirmation(value: unknown, taskId: string, resumeId: string): Record<string, unknown> {
|
||||
const record = asRecord(value) ?? {};
|
||||
const confirmation = asRecord(record.confirmation) ?? record;
|
||||
const trace = asRecord(confirmation.trace);
|
||||
const compactTrace = trace === null ? null : {
|
||||
seq: trace.seq ?? null,
|
||||
at: trace.at ?? null,
|
||||
method: trace.method ?? null,
|
||||
resumeId: trace.resumeId ?? resumeId,
|
||||
promptChars: trace.promptChars ?? null,
|
||||
promptHash: trace.promptHash ?? null,
|
||||
promptOmitted: true,
|
||||
source: trace.source ?? null,
|
||||
};
|
||||
return {
|
||||
taskId: asString(confirmation.taskId) || taskId,
|
||||
resumeId: asString(confirmation.resumeId) || resumeId,
|
||||
found: confirmation.found === true,
|
||||
accepted: confirmation.accepted === true,
|
||||
deliveryState: asString(confirmation.deliveryState) || (confirmation.found === true ? "queued_for_existing_thread" : "unknown"),
|
||||
matchCount: asNumber(confirmation.matchCount, 0),
|
||||
trace: compactTrace,
|
||||
duplicateSuppressionKey: confirmation.duplicateSuppressionKey ?? resumeId,
|
||||
promptOmitted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function terminalStatusFromResumeTask(task: Record<string, unknown> | null): string {
|
||||
return terminalStatusFromTask(task);
|
||||
}
|
||||
|
||||
function compactResumeTaskConfirmation(task: unknown, resumeId: string, source: Record<string, unknown>): Record<string, unknown> {
|
||||
const compact = compactSubmitTaskConfirmation(task);
|
||||
const commands = asRecord(compact.commands) ?? {};
|
||||
const record = asRecord(task) ?? {};
|
||||
return {
|
||||
...compact,
|
||||
currentAttempt: record.currentAttempt ?? null,
|
||||
currentMode: record.currentMode ?? null,
|
||||
terminalStatus: terminalStatusFromResumeTask(record) || null,
|
||||
reusedCodexThreadId: source.originalCodexThreadId ?? source.codexThreadId ?? record.codexThreadId ?? null,
|
||||
commands: {
|
||||
...commands,
|
||||
show: `bun scripts/cli.ts codex task ${asString(compact.id) || "<taskId>"}`,
|
||||
detail: `bun scripts/cli.ts codex task ${asString(compact.id) || "<taskId>"} --detail`,
|
||||
trace: `bun scripts/cli.ts codex task ${asString(compact.id) || "<taskId>"} --trace --tail --limit ${defaultTraceLimit}`,
|
||||
output: `bun scripts/cli.ts codex output ${asString(compact.id) || "<taskId>"} --tail --limit ${defaultOutputLimit}`,
|
||||
resumeAgain: resumeCommand(asString(compact.id) || "<taskId>", resumeId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function orderedUniqueStringList(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const items: string[] = [];
|
||||
@@ -6461,6 +6555,147 @@ function codexSteerTraceConfirm(taskId: string, args: string[], fetcher: CodexRe
|
||||
};
|
||||
}
|
||||
|
||||
function compactResumeRejection(taskId: string, resumeId: string, response: unknown, options: CodexResumeOptions): Record<string, unknown> {
|
||||
const record = asRecord(response);
|
||||
const body = responseBody(record);
|
||||
const task = asRecord(body?.task);
|
||||
const status = asString(task?.status);
|
||||
const upstreamStatus = responseStatus(record);
|
||||
const bodyError = asString(body?.error);
|
||||
const disposition = asString(body?.disposition)
|
||||
|| (upstreamStatus === 404 || bodyError?.toLowerCase().includes("not found") ? "task-not-found" : isActiveTaskStatus(status) ? "use-steer-for-active-task" : isTerminalTaskStatus(status) ? "resume-rejected" : "not-terminal");
|
||||
const result: Record<string, unknown> = {
|
||||
ok: false,
|
||||
resume: {
|
||||
accepted: false,
|
||||
status: "not_accepted" satisfies CodexResumeAcceptanceStatus,
|
||||
deliveryState: "not_accepted" satisfies CodexResumeDeliveryState,
|
||||
resumeId,
|
||||
reason: disposition,
|
||||
taskId,
|
||||
taskStatus: status || null,
|
||||
terminalStatus: terminalStatusFromResumeTask(task) || null,
|
||||
retryable: false,
|
||||
promptOmitted: true,
|
||||
},
|
||||
message: asString(body?.error) || `task ${taskId} is not resumable`,
|
||||
commands: {
|
||||
show: `bun scripts/cli.ts codex task ${taskId}`,
|
||||
detail: `bun scripts/cli.ts codex task ${taskId} --detail`,
|
||||
...(disposition === "use-steer-for-active-task" ? { steer: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>` } : {}),
|
||||
supervisor: `bun scripts/cli.ts codex tasks --view supervisor --limit ${defaultTasksLimit}`,
|
||||
},
|
||||
upstream: {
|
||||
status: upstreamStatus,
|
||||
error: bodyError || null,
|
||||
},
|
||||
disclosure: {
|
||||
defaultPolicy: "compact resume rejection; request prompt, upstream task body, and raw response require explicit --full or --raw",
|
||||
full: options.full,
|
||||
raw: options.raw,
|
||||
defaultOmitted: ["request.body.prompt.text", "task.prompt", "task.finalResponse", "rawFailure"],
|
||||
},
|
||||
};
|
||||
if (options.full) {
|
||||
result.task = compactResumeTaskConfirmation(task, resumeId, body ?? {});
|
||||
result.upstreamBodyPreview = previewJson(body ?? record, { maxDepth: 4, maxArrayItems: 8, maxObjectKeys: 24, maxStringLength: 600 });
|
||||
}
|
||||
if (options.raw) result.rawFailure = response;
|
||||
return result;
|
||||
}
|
||||
|
||||
function codexResumeTask(taskId: string, args: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
const options = parseResumeOptions(args);
|
||||
const resumeId = options.resumeId ?? createResumeId(taskId, options.prompt);
|
||||
const targetPath = `/api/tasks/${encodeURIComponent(taskId)}/resume`;
|
||||
const stableProxyPath = codeQueueProxyPath(targetPath);
|
||||
const rawProxyEquivalent = codeQueueProxyEquivalentCommand(targetPath, `{"prompt":"...","resumeId":"${resumeId}"}`);
|
||||
const prompt = textView(options.prompt, false, steerPromptPreviewChars);
|
||||
const request = {
|
||||
path: targetPath,
|
||||
stableProxyPath,
|
||||
method: "POST",
|
||||
resumeId,
|
||||
bodySummary: {
|
||||
resumeId,
|
||||
promptChars: options.prompt.length,
|
||||
promptPreviewChars: steerPromptPreviewChars,
|
||||
promptTruncated: prompt.truncated,
|
||||
},
|
||||
body: { resumeId, prompt },
|
||||
contract: {
|
||||
target: "terminal-or-awaiting-closeout task follow-up turn",
|
||||
idempotency: "same resumeId and same prompt is duplicate-suppressed; same resumeId and different prompt is rejected as conflict",
|
||||
activeTaskDisposition: "active running/judging tasks must use codex steer, not resume",
|
||||
promptEchoPolicy: "default dry-run shows only bounded prompt preview; non-dry-run never echoes prompt text",
|
||||
},
|
||||
};
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
request,
|
||||
commands: {
|
||||
run: resumeCommand(taskId, resumeId),
|
||||
show: `bun scripts/cli.ts codex task ${taskId}`,
|
||||
supervisor: `bun scripts/cli.ts codex tasks --view supervisor --limit ${defaultTasksLimit}`,
|
||||
rawProxy: rawProxyEquivalent,
|
||||
},
|
||||
};
|
||||
}
|
||||
const rawResponse = fetcher(stableProxyPath, { method: "POST", body: { prompt: options.prompt, resumeId } });
|
||||
const record = asRecord(rawResponse);
|
||||
const body = responseBody(record);
|
||||
if (record?.ok !== true || body?.ok !== true) {
|
||||
return compactResumeRejection(taskId, resumeId, rawResponse, options);
|
||||
}
|
||||
const responseResumeId = asString(body.resumeId) || resumeId;
|
||||
const duplicateSuppressed = body.duplicateSuppressed === true;
|
||||
const traceConfirmation = compactResumeTraceConfirmation(body.traceConfirmation, taskId, responseResumeId);
|
||||
const deliveryState = asString(body.deliveryState) || asString(traceConfirmation.deliveryState) || (duplicateSuppressed ? "duplicate_suppressed" : "queued_for_existing_thread");
|
||||
const acceptanceStatus: CodexResumeAcceptanceStatus = duplicateSuppressed ? "duplicate_suppressed" : deliveryState === "accepted_response_timeout" ? "accepted_response_timeout" : "queued";
|
||||
const traceRecord = asRecord(traceConfirmation.trace);
|
||||
return {
|
||||
ok: true,
|
||||
upstream: { ok: record.ok, status: record.status },
|
||||
resume: {
|
||||
accepted: true,
|
||||
status: acceptanceStatus,
|
||||
deliveryState,
|
||||
resumeId: responseResumeId,
|
||||
turnId: body.turnId ?? traceRecord?.seq ?? null,
|
||||
taskId,
|
||||
promptChars: options.prompt.length,
|
||||
promptOmitted: true,
|
||||
duplicateSuppressed,
|
||||
duplicateSuppressionKey: responseResumeId,
|
||||
reusedCodexThread: body.reuseOriginalThread === true,
|
||||
originalCodexThreadId: body.originalCodexThreadId ?? null,
|
||||
codexThreadId: body.codexThreadId ?? null,
|
||||
deliveryUnconfirmed: deliveryState === "accepted_response_timeout",
|
||||
outputPolicy: {
|
||||
default: "write-confirmation",
|
||||
promptEchoed: false,
|
||||
taskDetailEchoed: false,
|
||||
reason: "codex resume is a write operation; default output confirms queued delivery and provides drill-down commands without echoing prompt text or full task state.",
|
||||
},
|
||||
},
|
||||
traceConfirmation,
|
||||
task: compactResumeTaskConfirmation(body.task, responseResumeId, body),
|
||||
queue: compactSubmitQueueConfirmation(body.queue),
|
||||
commands: {
|
||||
show: `bun scripts/cli.ts codex task ${taskId}`,
|
||||
detail: `bun scripts/cli.ts codex task ${taskId} --detail`,
|
||||
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
|
||||
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
|
||||
retrySameResumeId: resumeCommand(taskId, responseResumeId),
|
||||
supervisor: `bun scripts/cli.ts codex tasks --view supervisor --limit ${defaultTasksLimit}`,
|
||||
confirmDelivery: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
|
||||
...(deliveryState === "accepted_response_timeout" ? { deliveryUnconfirmed: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}` } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "task", taskIdArg] = args;
|
||||
if (action === "prompt-lint" || action === "lint-prompt") {
|
||||
@@ -6519,9 +6754,13 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[])
|
||||
const taskId = requireTaskId(taskIdArg, "codex steer");
|
||||
return codexSteerTask(taskId, args.slice(2));
|
||||
}
|
||||
if (action === "resume") {
|
||||
const taskId = requireTaskId(taskIdArg, "codex resume");
|
||||
return codexResumeTask(taskId, args.slice(2));
|
||||
}
|
||||
if (action === "steer-confirm" || action === "steer-confirmation") {
|
||||
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
|
||||
return codexSteerTraceConfirm(taskId, args.slice(2));
|
||||
}
|
||||
throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, steer-confirm, interrupt, cancel");
|
||||
throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, resume, steer-confirm, interrupt, cancel");
|
||||
}
|
||||
|
||||
+3
-1
@@ -64,6 +64,7 @@ export function rootHelp(): unknown {
|
||||
{ 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] [--steer-id id] [--dry-run] [--no-retry|--retry-attempts N] [--full|--raw]", description: "Push a corrective prompt into a running Code Queue task with a steerId/idempotency key; retryable tunnel aborts get bounded trace confirmation before any retry guidance." },
|
||||
{ command: "codex resume <taskId> [prompt|--prompt-file path|--prompt-stdin] [--resume-id id] [--dry-run] [--full|--raw]", description: "Queue a follow-up turn on a terminal Code Queue task, preserving the original task/thread/PR context and suppressing duplicate resumeId delivery without echoing the prompt." },
|
||||
{ command: "codex steer-confirm <taskId> --steer-id <id> [--raw]", description: "Read-only lookup for a steerId in task trace so deliveryUnconfirmed can be resolved without resending the corrective prompt." },
|
||||
{ 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, including effective activity counts that distinguish scheduler-local queues, DB running tasks, and heartbeat-fresh runners; full queue rows require --full/--all." },
|
||||
@@ -249,7 +250,7 @@ function scheduleHelp(): unknown {
|
||||
|
||||
function codexHelp(): unknown {
|
||||
return {
|
||||
command: "codex deploy|prompt-lint|submit|task|tasks|unread|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move",
|
||||
command: "codex deploy|prompt-lint|submit|task|tasks|unread|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|resume|interrupt|cancel|queues|queue|move",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts codex deploy <commitId> # disabled legacy deployment entry",
|
||||
@@ -268,6 +269,7 @@ function codexHelp(): unknown {
|
||||
"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] [--full|--raw]",
|
||||
"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] [--steer-id id] [--dry-run] [--no-retry|--retry-attempts N] [--full|--raw]",
|
||||
"bun scripts/cli.ts codex resume <taskId> [prompt|--prompt-file path|--prompt-stdin] [--resume-id id] [--dry-run] [--full|--raw]",
|
||||
"bun scripts/cli.ts codex steer-confirm <taskId> --steer-id <id> [--raw]",
|
||||
"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>",
|
||||
|
||||
Reference in New Issue
Block a user