diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c428f425..487f3f28 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -47,7 +47,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `codex read ` 在人工审阅后标记单个终态任务已读;列表、overview 和 supervisor 视图只返回这个命令字段,不得自动执行,也不得批量清空未读状态。 - `codex dev-ready` 查询 Code Queue `/api/dev-ready` 并返回有界 readiness 摘要,包括工具、Docker、Codex config、SSH 和 `devReady.skills`。`devReady.skills` 只暴露 `UNIDESK_SKILLS_PATH`、是否存在、是否只读、skillCount、`cli-spec` 是否可见和修复建议,不输出宿主 auth/token 文件内容。 - `codex judge --attempt N [--dry-run] [--include-prompt]` 通过 Code Queue 私有代理按指定 attempt 单步复现 judge;这是执行面诊断入口,仍依赖 D601 scheduler/runner 侧的真实 judge builder、MiniMax 调用路径和执行环境。默认会真实调用 MiniMax,`--dry-run` 只返回 prompt/payload 大小、attempt 窗口和重建来源诊断,`--include-prompt` 仅用于本地深度排查。 -- `codex steer [prompt|--prompt-file path|--prompt-stdin] [--dry-run]` 通过 Code Queue 私有代理向正在运行的 task 注入纠偏提示,正式替代底层 `microservice proxy code-queue /api/tasks//steer` 调用。prompt 必须且只能来自位置参数、文件或 stdin 之一;`--dry-run` 只输出将要发送的结构化请求和 prompt 字符数,不触碰运行中 session。真实执行仍走 `/api/microservices/code-queue/proxy/api/tasks//steer`,只能作用于 D601 scheduler 上存在 active steerable turn 的 running task;若任务不在 running/active-turn 状态,返回上游 409 和有界 task 摘要,不得静默成功。 +- `codex steer [prompt|--prompt-file path|--prompt-stdin] [--dry-run]` 通过 Code Queue 私有代理向正在运行的 task 注入纠偏提示,正式替代底层 `microservice proxy code-queue /api/tasks//steer` 调用。prompt 必须且只能来自位置参数、文件或 stdin 之一;`--dry-run` 只输出 `method`、`path`、`stableProxyPath`、prompt 字符数、截断预览和 raw proxy 等价命令,不触碰运行中 session,也不得泄露超长 prompt 全文。真实执行复用与 `codex task/tasks/read` 相同的 backend-core stable proxy helper,路径固定为 `/api/microservices/code-queue/proxy/api/tasks//steer`,只能作用于 D601 scheduler 上存在 active steerable turn 的 running task。 +- `codex steer` 非 dry-run 失败仍输出 JSON 且退出非零;`.data.diagnostics.reason` 用于 runner 分流,当前包括 `backend-core-unreachable`、`code-queue-microservice-unregistered`、`proxy-unauthorized`、`proxy-404`、`steer-endpoint-404`、`upstream-runtime-rejected`、`stable-proxy-failed` 和 `invalid-proxy-response`。`scope` 区分 `backend-core`、`stable-proxy`、`code-queue-runtime` 或 `unknown`,并带 `status`、`exitCode`、`retryable`、有界 `upstreamBodyPreview` 和推荐交叉验证命令;若任务不在 running/active-turn 状态,通常归类为 `upstream-runtime-rejected`,不得静默成功。 - `codex interrupt|cancel ` 通过 Code Queue 私有代理请求中断;running/judging 任务会请求 D601 当前 agent run 停止,queued/retry_wait 任务的取消也必须保持与 WebUI 相同代理路径,返回有界 task 摘要和后续查询命令。任何需要接触 active run 的动作仍属于 D601 执行面。 - Code Queue 多队列 lane 由 `codex` 命令命名空间管理:`queues [--full|--all]` 列表、`queue create ` 创建、`queue merge --into ` 合并、`move --queue ` 迁移;这些队列管理入口默认由主 server `code-queue-mgr` 直管 PostgreSQL,仍通过稳定 `code-queue` 用户服务代理路径访问。`codex queues` 默认只返回 active/nonempty/unread/runnable queue 摘要、全局 counts 和 execution diagnostics;完整队列数组必须显式 `--full` 或 `--all`。同一个 queue 内部串行执行,不同 queue 之间并行执行。迁移只允许尚未被 scheduler claim 的 `queued`/`retry_wait` 任务,必须满足 `startedAt=null`、`currentAttempt=0` 且没有 active thread/turn;已进入 `running`/`judging` 或已有 claim 标记的任务返回 409,不得被 move/merge 回写成 queued。合并会移动可迁移任务归属并自动删除源 queue 记录,只保留合并后的目标 queue;若 source 或 target queue 存在 active/claimed 任务,合并整体返回 409。合并后的目标 queue 按任务原 `queueEnteredAt`/`createdAt` 时间顺序串行,成功迁移 queued/retry_wait 任务后由 D601 scheduler 轮询推进。 - 所有 `codex` 查询和管理命令必须走与 WebUI 相同的 backend-core 私有代理路径 `/api/microservices/code-queue/proxy/...`;CLI 不得为了提交、移动、中断、取消或队列管理直接调用 D601 内部 Service、数据库、pod curl 或 k3sctl scheduler 子服务。若该路径失败,应先修复 CLI/backend/provider tunnel 链路,而不是绕过控制面。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index f3d397d6..8999d891 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -182,7 +182,7 @@ bun scripts/code-queue-pr-preflight-example.ts --repo pikasTech/unidesk --base m 只有存在明确理由时才干预。 - 如果任务还在运行且 trace 或 scheduler heartbeat 新鲜,应引导而不是 interrupt。 -- 对运行中任务的引导应优先使用正式 CLI。若源码或 runtime 已有 `steer` 能力但 CLI 尚未暴露,临时通过受控 microservice proxy 调用可以作为现场恢复手段;这类临时绕行必须记录到指挥简报 issue #24 主体的常驻观察,并创建正式 issue 补齐 CLI 能力,避免长期依赖隐式 API。 +- 对运行中任务的引导应优先使用正式 CLI:`bun scripts/cli.ts codex steer --prompt-file `。该命令和 `codex task/tasks/read` 复用同一个 backend-core stable proxy helper;`--dry-run` 会显示 `method/path/stableProxyPath`、prompt 摘要和 raw proxy 等价命令但不发送。非 dry-run 失败时先看 `.data.diagnostics.reason`:`backend-core-unreachable` 属于本机到 core 的观察路径,`code-queue-microservice-unregistered`/`proxy-unauthorized`/`proxy-404` 属于 stable proxy 配置或权限,`steer-endpoint-404`/`upstream-runtime-rejected` 属于 D601 runtime 或任务状态,`stable-proxy-failed` 多为 provider/k3s/tunnel 链路问题。若正式 CLI 自身不可用,临时通过受控 microservice proxy 调用只能作为现场恢复手段;这类绕行必须记录到指挥简报 issue #24 主体的常驻观察,并创建正式 issue 补齐 CLI 能力,避免长期依赖隐式 API。 - 如果任务进入终态但缺少必要验收证据,应使用聚焦 continuation prompt retry 同一任务。 - 如果任务被可复用基础设施缺陷阻塞,应把该缺陷分配给合适的空闲或低风险队列,让原业务任务等待,或在修复后 retry。 - 如果基础设施缺陷影响 Code Queue 控制面可用性,指挥官可以执行恢复队列所需的最小受控部署,然后验证原任务能继续。 diff --git a/scripts/cli.ts b/scripts/cli.ts index 2388c3c4..76cdf202 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -23,7 +23,28 @@ import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from const remoteOptions = extractRemoteCliOptions(process.argv.slice(2)); const args = remoteOptions.args; -const commandName = args.join(" ") || "help"; +const commandName = displayCommandName(args); + +function displayCommandName(parts: string[]): string { + if (parts.length === 0) return "help"; + if (parts[0] === "codex" && parts[1] === "steer" && parts[2] !== undefined) { + const shown = ["codex", "steer", parts[2]]; + const hasPromptFile = parts.includes("--prompt-file") || parts.includes("--file"); + const hasPromptStdin = parts.includes("--prompt-stdin") || parts.includes("--stdin"); + if (!hasPromptFile && !hasPromptStdin) shown.push(""); + for (let index = 3; index < parts.length; index += 1) { + const part = parts[index] ?? ""; + if (!part.startsWith("--")) continue; + shown.push(part); + if (part === "--prompt-file" || part === "--file") { + shown.push(parts[index + 1] ?? ""); + index += 1; + } + } + return shown.join(" "); + } + return parts.join(" "); +} function numberOption(name: string, defaultValue: number): number { const index = args.indexOf(name); @@ -252,7 +273,10 @@ async function main(): Promise { if (!ok) process.exitCode = 1; return; } - emitJson(commandName, await runCodeQueueCommand(config, args.slice(1))); + const result = await runCodeQueueCommand(config, args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; return; } diff --git a/scripts/code-queue-cli-steer-test.ts b/scripts/code-queue-cli-steer-test.ts index 02a2aea4..bbbce638 100644 --- a/scripts/code-queue-cli-steer-test.ts +++ b/scripts/code-queue-cli-steer-test.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { writeFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { codexSteerTaskForTest } from "./src/code-queue"; type JsonRecord = Record; @@ -48,15 +49,35 @@ 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/steer", "dry-run should expose request path", request); + assertCondition(request.stableProxyPath === "/api/microservices/code-queue/proxy/api/tasks/codex_test_task/steer", "dry-run should expose stable proxy path", 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); assertCondition(prompt.truncated === false, "dry-run prompt must not truncate", prompt); + const bodySummary = nestedRecord(response.data, ["request", "bodySummary"]); + assertCondition(bodySummary.promptChars === expectedText.length, "dry-run should expose body prompt char count", bodySummary); + const commands = nestedRecord(response.data, ["commands"]); + assertCondition(String(commands.rawProxy || "").includes("microservice proxy code-queue /api/tasks/codex_test_task/steer --method POST"), "dry-run should expose raw proxy equivalent", commands); +} + +function assertReason(result: unknown, reason: string, status: number | null): void { + const data = nestedRecord({ data: result }, ["data"]); + assertCondition(data.ok === false, "classified steer failure should be ok=false", data); + const diagnostics = nestedRecord(data, ["diagnostics"]); + assertCondition(diagnostics.reason === reason, "unexpected steer failure reason", diagnostics); + assertCondition(diagnostics.status === status, "unexpected steer failure status", diagnostics); + assertCondition(typeof diagnostics.retryable === "boolean", "diagnostics should expose retryable boolean", diagnostics); + assertCondition(Array.isArray(diagnostics.recommendedCrossChecks), "diagnostics should expose cross-check commands", diagnostics); } export function runCodeQueueCliSteerContract(): JsonRecord { const positional = runCli(["codex", "steer", "codex_test_task", "correct the running task", "--dry-run"]); assertDryRunPrompt(positional.json ?? {}, "correct the running task"); + assertCondition(String(positional.json?.command || "").includes(""), "outer command should redact positional steer prompt", positional.json ?? {}); + assertCondition(!String(positional.json?.command || "").includes("correct the running task"), "outer command must not echo positional steer prompt", positional.json ?? {}); const stdin = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], "stdin steer prompt\n"); assertDryRunPrompt(stdin.json ?? {}, "stdin steer prompt\n"); @@ -85,6 +106,51 @@ export function runCodeQueueCliSteerContract(): JsonRecord { const usage = stringArray(nestedRecord(help.json?.data, []).usage); assertCondition(usage.some((line) => line.includes("codex steer ")), "codex help should list steer", { usage }); + let dryRunFetchCount = 0; + const dryRunDirect = codexSteerTaskForTest("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 = codexSteerTaskForTest("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 = ""; + const success = codexSteerTaskForTest("direct_task", ["send this"], (path, init) => { + fetchPath = path; + fetchMethod = String(init?.method || ""); + fetchPrompt = String((init?.body as JsonRecord | undefined)?.prompt || ""); + return { + ok: true, + status: 200, + body: { + ok: true, + task: { id: "direct_task", status: "running", prompt: "p" }, + queue: { activeTaskIds: ["direct_task"] }, + }, + }; + }) as JsonRecord; + assertCondition(fetchPath === "/api/microservices/code-queue/proxy/api/tasks/direct_task/steer", "non-dry-run should use stable proxy path", { fetchPath }); + assertCondition(fetchMethod === "POST", "non-dry-run should POST", { fetchMethod }); + assertCondition(fetchPrompt === "send this", "non-dry-run should send raw prompt in body", { fetchPrompt }); + assertCondition(nestedRecord(success, ["steer"]).accepted === true, "successful steer should report accepted=true", 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); + return { ok: true, checks: [ @@ -94,6 +160,11 @@ export function runCodeQueueCliSteerContract(): JsonRecord { "duplicate prompt source failure", "unsupported option failure", "codex help lists steer", + "outer command redacts positional steer prompt", + "dry-run does not call stable proxy helper", + "dry-run prompt preview is bounded", + "non-dry-run uses stable proxy helper", + "steer failure classification is JSON-consumable", ], }; } diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 35006000..6d1b0994 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -1,6 +1,7 @@ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { coreInternalFetch } from "./microservices"; +import { previewJson } from "./preview"; const defaultToolLimit = 8; const defaultTraceLimit = 80; @@ -9,6 +10,7 @@ const defaultOutputLimit = 20; const defaultTextPreviewChars = 12_000; const defaultTasksLimit = 20; const maxTasksLimit = 100; +const steerPromptPreviewChars = 320; const submitLockWaitMs = 60_000; const submitLockPollMs = 250; const submitLockStaleMs = 120_000; @@ -58,6 +60,30 @@ interface CodexSteerOptions { dryRun: boolean; } +type CodexSteerFailureReason = + | "backend-core-unreachable" + | "code-queue-microservice-unregistered" + | "proxy-unauthorized" + | "proxy-404" + | "steer-endpoint-404" + | "upstream-runtime-rejected" + | "stable-proxy-failed" + | "invalid-proxy-response"; + +interface ClassifiedCodexSteerError { + reason: CodexSteerFailureReason; + scope: "backend-core" | "stable-proxy" | "code-queue-runtime" | "unknown"; + status: number | null; + exitCode: number | null; + retryable: boolean; + message: string; + requestPath: string; + stableProxyPath: string; + upstreamBodyPreview: unknown; + rawProxyEquivalent: string; + recommendedCrossChecks: string[]; +} + interface CompactTaskMutationResponseOptions { fullPrompt?: boolean; } @@ -129,6 +155,10 @@ function codeQueueProxyPath(path: string): string { return `${codeQueueProxyPrefix}${path}`; } +function codeQueueProxyEquivalentCommand(targetPath: string, bodyJson: string): string { + return `bun scripts/cli.ts microservice proxy code-queue ${targetPath} --method POST --body-json '${bodyJson}'`; +} + function nonNegativeIntegerEnv(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw.trim().length === 0) return fallback; @@ -285,6 +315,103 @@ function unwrapCodexResponse(response: unknown): { upstream: { ok: unknown; stat return { upstream: { ok: record.ok, status: record.status }, body }; } +function responseStatus(response: Record | null): number | null { + const value = response?.status; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function responseExitCode(response: Record | null): number | null { + const value = response?.exitCode ?? response?.commandExitCode; + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function responseBody(response: Record | null): Record | null { + return asRecord(response?.body); +} + +function responseErrorMessage(response: Record | null): string { + const body = responseBody(response); + const bodyError = body?.error; + if (typeof bodyError === "string" && bodyError.length > 0) return 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; + return "Code Queue steer request failed"; +} + +function classifySteerFailure(response: unknown, targetPath: string, stableProxyPath: string, rawProxyEquivalent: string): ClassifiedCodexSteerError { + const record = asRecord(response); + const status = responseStatus(record); + const exitCode = responseExitCode(record); + const body = responseBody(record); + const message = responseErrorMessage(record); + const lowerMessage = message.toLowerCase(); + const bodyPath = typeof body?.path === "string" ? body.path : ""; + const bodyStage = typeof body?.stage === "string" ? body.stage : ""; + const bodyServiceId = typeof body?.serviceId === "string" ? body.serviceId : ""; + + let reason: CodexSteerFailureReason = "invalid-proxy-response"; + let scope: ClassifiedCodexSteerError["scope"] = "unknown"; + let retryable = false; + + if (record === null || status === null && exitCode !== null) { + reason = "backend-core-unreachable"; + scope = "backend-core"; + retryable = true; + } else if (status === 401 || status === 403) { + reason = "proxy-unauthorized"; + scope = "stable-proxy"; + retryable = false; + } else if (status === 404 && /microservice not found: code-queue/u.test(lowerMessage)) { + reason = "code-queue-microservice-unregistered"; + scope = "stable-proxy"; + retryable = false; + } else if (status === 404 && (lowerMessage === "task not found" || lowerMessage === "not found" || bodyPath === targetPath || bodyServiceId === "code-queue")) { + reason = "steer-endpoint-404"; + scope = "code-queue-runtime"; + retryable = false; + } else if (status === 404) { + reason = "proxy-404"; + scope = "stable-proxy"; + retryable = false; + } else if (status === 400 || status === 405 || status === 409 || /active run|steerable|scheduler-only|read-only|prompt is required/u.test(lowerMessage)) { + reason = "upstream-runtime-rejected"; + scope = "code-queue-runtime"; + retryable = status === 409; + } else if (status === 502 || status === 503 || status === 504 || /proxy|tunnel|provider|k3sctl|adapter|timed out|timeout|unavailable|disconnected/u.test(lowerMessage) || bodyStage.length > 0) { + reason = "stable-proxy-failed"; + scope = "stable-proxy"; + retryable = true; + } + + return { + reason, + scope, + status, + exitCode, + retryable, + message, + requestPath: targetPath, + stableProxyPath, + upstreamBodyPreview: previewJson(body ?? record, { maxDepth: 3, maxArrayItems: 3, maxObjectKeys: 16, maxStringLength: 320 }), + rawProxyEquivalent, + recommendedCrossChecks: [ + "bun scripts/cli.ts codex queues", + "bun scripts/cli.ts codex tasks --view supervisor --limit 20", + "bun scripts/cli.ts codex task ", + "bun scripts/cli.ts microservice health code-queue", + "bun scripts/cli.ts microservice diagnostics code-queue", + ], + }; +} + +function unwrapSteerResponse(response: unknown, targetPath: string, stableProxyPath: string, rawProxyEquivalent: string): { ok: true; upstream: { ok: unknown; status: unknown }; body: Record } | { ok: false; diagnostics: ClassifiedCodexSteerError } { + const record = asRecord(response); + const body = responseBody(record); + if (record?.ok === true && body?.ok === true) return { ok: true, upstream: { ok: record.ok, status: record.status }, body }; + return { ok: false, diagnostics: classifySteerFailure(response, targetPath, stableProxyPath, rawProxyEquivalent) }; +} + function positiveIntegerOption(args: string[], names: string[], defaultValue: number, maxValue = Number.MAX_SAFE_INTEGER): number { for (const name of names) { const index = args.indexOf(name); @@ -917,6 +1044,10 @@ export function codexJudgeQuery(taskId: string, optionArgs: string[], fetcher: C return codexTaskJudge(taskId, parseJudgeOptions(optionArgs), fetcher); } +export function codexSteerTaskForTest(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher): unknown { + return codexSteerTask(taskId, optionArgs, fetcher); +} + function isTerminalTaskStatus(status: unknown): boolean { return status === "succeeded" || status === "failed" || status === "canceled"; } @@ -1779,12 +1910,21 @@ function codexInterruptTask(taskId: string): unknown { }; } -function codexSteerTask(taskId: string, args: string[]): unknown { +function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown { const options = parseSteerOptions(args); - const prompt = textView(options.prompt, true, 3000); + const targetPath = `/api/tasks/${encodeURIComponent(taskId)}/steer`; + const stableProxyPath = codeQueueProxyPath(targetPath); + const rawProxyEquivalent = codeQueueProxyEquivalentCommand(targetPath, "{\"prompt\":\"...\"}"); + const prompt = textView(options.prompt, false, steerPromptPreviewChars); const request = { - path: `/api/tasks/${taskId}/steer`, + path: targetPath, + stableProxyPath, method: "POST", + bodySummary: { + promptChars: options.prompt.length, + promptPreviewChars: steerPromptPreviewChars, + promptTruncated: prompt.truncated, + }, body: { prompt }, }; if (options.dryRun) { @@ -1794,11 +1934,28 @@ function codexSteerTask(taskId: string, args: string[]): unknown { request, commands: { run: `bun scripts/cli.ts codex steer ${taskId} --prompt-file `, - rawProxy: `bun scripts/cli.ts microservice proxy code-queue /api/tasks/${encodeURIComponent(taskId)}/steer --method POST --body-json '{"prompt":"..."}'`, + rawProxy: rawProxyEquivalent, + }, + }; + } + const response = unwrapSteerResponse(fetcher(stableProxyPath, { method: "POST", body: { prompt: options.prompt } }), targetPath, stableProxyPath, rawProxyEquivalent); + if (!response.ok) { + return { + ok: false, + steer: { + accepted: false, + prompt, + }, + request, + diagnostics: response.diagnostics, + commands: { + dryRun: `bun scripts/cli.ts codex steer ${taskId} --prompt-file --dry-run`, + rawProxy: rawProxyEquivalent, + tasks: "bun scripts/cli.ts codex tasks --view supervisor --limit 20", + health: "bun scripts/cli.ts microservice health code-queue", }, }; } - const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/steer`), { method: "POST", body: { prompt: options.prompt } })); return { upstream: response.upstream, steer: { diff --git a/scripts/src/microservices.ts b/scripts/src/microservices.ts index 05206a6a..1d65c64d 100644 --- a/scripts/src/microservices.ts +++ b/scripts/src/microservices.ts @@ -32,6 +32,14 @@ export function coreInternalFetch(path: string, init?: { method?: string; body?: const command = dockerCoreFetchCommand(path, init); const result = runCommand(command, repoRoot); if (result.exitCode !== 0) { + const parsedStdout = parseJsonRecord(result.stdout.trim()); + if (parsedStdout !== null) { + return { + ...parsedStdout, + commandExitCode: result.exitCode, + commandStderrTail: result.stderr.slice(-1200), + }; + } const codeQueueStableProxy = path.startsWith("/api/microservices/code-queue/"); return { ok: false, @@ -55,6 +63,16 @@ export function coreInternalFetch(path: string, init?: { method?: string; body?: } } +function parseJsonRecord(text: string): Record | null { + if (text.length === 0) return null; + try { + const parsed = JSON.parse(text) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; + } catch { + return null; + } +} + function requireId(value: string | undefined, command: string): string { if (value === undefined || value.length === 0) throw new Error(`${command} requires microservice id`); return value;