From 0f1d93a028ae569c753f90d2a70899c4d7d1ed57 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 10:19:08 +0000 Subject: [PATCH] fix codex pr-preflight remote fallback --- docs/reference/cli.md | 2 +- docs/reference/code-queue-supervision.md | 25 +- .../code-queue-pr-preflight-contract-test.ts | 364 ++++++++++++------ scripts/src/code-queue.ts | 272 ++++++++++++- scripts/src/remote.ts | 10 +- 5 files changed, 530 insertions(+), 143 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 215332c6..42788405 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -43,7 +43,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `schedule list|get|runs|run|retry-run|delete|upsert-pgdata-backup` 管理 backend-core 定时任务和运行历史。`schedule list`、`schedule get`、`schedule runs --limit N` 和 `schedule runs --limit N` 是只读观察入口;`schedule run`、`schedule retry-run`、`schedule delete` 和 `schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global` 和 `scheduleId=null`;`schedule runs --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId` 和 `observeCommand`;`schedule retry-run ` 只接受 failed run,从原 run 反查 `scheduleId` 后重触发同一 schedule,并输出 `originalRunId`、`scheduleId`、`newRunId` 和 `observeCommand`。当 backend-core 目标容器缺失或只观察到 verify-only 容器时,schedule/microservice 命令必须以非零退出并返回 `failureKind=target-stack-not-running`、`runnerDisposition=infra-blocked`、`readOnlyCommands` 和 `authorizationRequiredForRecovery`,不得把 Docker 的 `No such container` 当成成功的空历史。 - `codex deploy ` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。dry-run 会额外输出 `routingRecommendation`,包含推荐 route、runner、model、风险信号、prompt 自包含/issue 非唯一来源/prod-secret-DB 禁止/运行态或 release 禁止/证据要求/中等复杂度候选等 guard 状态;同时输出 `policyContract`,固定暴露 GPT-5.5、DeepSeek、MiniMax 的风险分层、并发上限和外部 provider 429 退避处置。该建议只用于指挥官 preflight,不会改写 payload,不改变 runtime admission,也不假设生产 MiniMax 或 DeepSeek 可用。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 -- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。缺少 env token 时顶层 `ok=false`、`runnerDisposition=infra-blocked`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token 来源 key、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff 和 `gh pr merge` 的 `unsupported-command` 边界。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token auth、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。 +- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/] [--pr-create-dry-run --pr-create-dry-run-head ] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。缺少 env token 时顶层 `ok=false`、`runnerDisposition=infra-blocked`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token 来源 key、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff 和 `gh pr merge` 的 `unsupported-command` 边界。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=proxy-gap` / `degradedReason=remote-control-plane-unreachable`,而不是把本地 `backend-core-container-missing` 当作最终阻塞。`--pr-create-dry-run` 不 POST GitHub,只证明 runner 内 PR body 生成、`scripts/cli.ts gh pr create --dry-run` 和 branch 参数形态可用;服务端创建权限仍以 token auth、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。 - `codex task ` 通过 Code Queue 私有代理按任务 ID 查询结构化审阅摘要;默认只返回任务身份、执行 Provider、工作目录、attempt 计数、原始 prompt、最终 response、最后错误和渐进披露命令,适合指挥官审阅完成未读任务且避免上下文爆炸。需要旧式详细摘要时显式加 `--detail`;需要完整 prompt/response 文本时加 `--full`;需要工具调用、judge、attempt 全量摘要时使用 `--detail --full --tool-limit N`。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。 - `codex tasks [--view supervisor|full] [--queue id] [--status succeeded|running|queued|failed|canceled|judging|retry_wait[,..]] [--unread|--unread-only] [--limit N] [--before-id id]` 通过同一私有代理输出渐进式披露视图。默认 `supervisor` 只返回 `running`、`completedUnread`、`recentCompleted`、`queued` 和 `executionDiagnostics` 摘要,不嵌入完整 Trace、final response 或全量 overview;每个条目都带 `commands.show`、`commands.trace`、`commands.output`、`commands.read` 和 `commands.full`。`--unread` 是 `--unread-only` 的别名,必须只保留未读终态;`--status` 必须真实过滤支持的状态,未知参数或未知状态必须结构化失败,不能静默忽略。需要完整当前页任务简表时显式使用 `--view full` 或 `--full`,仍受 `--limit` 和 `--before-id` 分页约束。 - `codex task --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,且仍以分页 trace 为主;需要完整 prompt/最终 response 时加 `--full`,需要详细 task 摘要时加 `--detail`。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 3f5ef995..ae8eb4f6 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -43,11 +43,26 @@ ## 模型和成本路由 -MiniMax/OpenCode runner 的可用性是 Code Queue 的主要能力方向之一。它不是可选实验项,而是控制 GPT-5.5 成本、扩大并发吞吐、让简单任务不占用高成本模型的基础设施能力。但 MiniMax 幻觉和伪造证据风险显著高于 GPT-5.5,因此只能承接低权限、低风险、低 blast radius 且可用外部证据复核的任务。 +Code Queue 派单模型按成本、可信度和 blast radius 分层:GPT-5.5/Codex 处理高风险和复杂任务,DeepSeek/Codex 处理中等复杂度且边界清晰的任务,MiniMax/OpenCode 处理简单、低权限、可复核任务,生产重启、密钥、数据库手工写入和运行中任务控制保留给指挥官或人工。 -MiniMax 路由策略的前置条件是 runner live smoke 稳定通过,并且失败能被结构化归类。#30 只证明 dev smoke 已有成功证据;生产 rollout 仍按受控节奏推进,指挥官不能假设 prod MiniMax 已可用。若 MiniMax/OpenCode 本身不可用,相关修复归入 Code Queue 运行态维护线;在修通前,不应用 MiniMax 承担会阻塞主交付链路的任务。 +当前提交合同由 `bun scripts/cli.ts codex submit` 暴露:prompt 必须来自位置参数、`--prompt-file` 或 `--prompt-stdin`;可选字段包括 `--queue/--queue-id`、`--provider-id/--provider`、`--cwd/--workdir`、`--model`、`--reasoning-effort`、`--execution-mode/--mode`、`--max-attempts` 和 `--reference-task-id/--reference/--ref`。这些字段写入任务 payload 后由 `code-queue-mgr` 入 PostgreSQL,核心任务字段包括 `queue_id`、`provider_id`、`execution_mode`、`model`、`cwd`、`prompt/base_prompt`、`reference_task_ids`、`reasoning_effort`、`max_attempts` 和 `task_json`;队列记录至少有 `id/name/created_at/updated_at`。模型治理应优先看任务 payload 和数据库字段,不靠 worker final response 自报。 -`codex submit --dry-run` 是派单前的轻量 preflight。它只输出 `routingRecommendation`,帮助指挥官看到推荐 runner/model、风险信号和缺失的 prompt guard;它不会修改真实提交 payload,也不会替代指挥官判断。真实派单是否使用 `--model minimax-m2.7` 仍由指挥官显式决定。 +运行态默认模型仍是 `gpt-5.5`。`CODE_QUEUE_MODELS` 当前长期合同至少包含 GPT-5.5、GPT-5.4、GPT-5.4 Mini 和 MiniMax M2.7;`minimax-m2.7` 会走 OpenCode port,其余模型走 Codex port。DeepSeek 属于 dry-run policy 和指挥治理层推荐模型;只有当执行面 `/health` 或等价配置已经显示 DeepSeek 模型可用、并完成轻量 runner smoke 后,才允许真实提交 `--model deepseek-chat`。补 DeepSeek 运行态配置必须作为单独任务处理,不能在派单治理任务里顺手改 admission 或 Kubernetes manifest。 + +`codex submit --dry-run` 是派单前的轻量 preflight。它只输出 `routingRecommendation` 和 `policyContract`,帮助指挥官看到推荐 runner/model、风险信号、缺失的 prompt guard、模型分层和并发上限;它不会修改真实提交 payload,也不会替代指挥官判断。真实派单是否使用 `--model minimax-m2.7`、`--model deepseek-chat` 或 `--model gpt-5.5` 仍由指挥官显式决定。 + +并发治理按模型和风险一起决定。GPT-5.5 常规并发目标是 5 条 lane;当写入范围互不重叠、heartbeat/trace 健康、完成质量稳定时可以短时提高到 10。MiniMax 只承接简单任务时可以提高到 10,但必须保留指挥官审阅和证据核验。DeepSeek 用于中等复杂度任务,默认按约 5 条 lane 观察质量,再根据成功率和 reviewer 负载逐步调整。并发扩张的前提永远是任务质量和可观测性,而不是模型价格。 + +模型选择矩阵: + +| 首选模型 | 适用任务 | 必须满足 | 禁止下放 | +| --- | --- | --- | --- | +| GPT-5.5/Codex | 高风险、复杂、跨模块、运行态、CI/CD、release、deploy、安全、最终质量裁决 | 多信号诊断、可回滚边界、必要的轻量或 dev 验证 | 不因成本把运行态和生产风险降级 | +| DeepSeek/Codex | 中等复杂度的前端功能、局部用户服务模块、局部 CLI/helper、明确 contract guard 或 unit test | prompt 自包含、写入范围窄、无生产/密钥/DB 写入、验证命令明确、指挥官审阅 | 不处理 Code Queue runtime、backend-core、provider-gateway、k3sctl-adapter、release/v1 或部署变更 | +| MiniMax/OpenCode | 只读调查、文档、简单前端/样式、低风险样板、轻量 dry-run/preflight 和小范围测试补齐 | issue 只作辅助引用、必须给出 diff/路径/命令证据、完成后保持未读待审 | 不处理共享核心、隐式远端状态、生产、密钥、DB、重启、复杂 bug 或最终裁决 | +| 指挥官/人工 | 真实生产动作、运行中任务控制、密钥/数据库/破坏性 Git、批量已读和高风险恢复 | 用户授权、只读诊断、恢复方案、记录 issue/#20/#24 | 不把执行权交给普通 worker | + +外部 token provider、模型 API 和上游服务限流遵循本文监控章节的退避规则;`policyContract.externalProvider429` 只是把同一规则暴露给 dry-run 调度判断。 MiniMax/OpenCode 可承担任务必须同时满足这些条件: @@ -136,7 +151,7 @@ Runner preflight 优先使用执行面诊断入口: bun scripts/cli.ts codex pr-preflight --remote --issue 20 ``` -该命令经 backend-core 稳定 `code-queue` proxy 访问 D601 scheduler 的 `/api/runtime-preflight`,报告 scheduler/runner 环境里的 `GH_TOKEN`/`GITHUB_TOKEN` 覆盖、工具、Git worktree、GitHub egress、repo/issue/PR 只读探测和可选 push dry-run。需要复核 PR body/创建命令 guard 时追加 `--pr-create-dry-run --pr-create-dry-run-head `;该 guard 只执行 dry-run,不创建 PR。缺少 env token 时必须返回 `ok=false`、`runnerDisposition=infra-blocked` 和 `tokenCoverage.missing=["GH_TOKEN","GITHUB_TOKEN"]`,因为 provider dev container 只能转发 scheduler 已经拥有的 token。输出中的 `prCapabilityContract` 用于指挥官快速审查 runner handoff:目标分支固定显示、push/PR create dry-run 标记为不写远端、系统 `gh` binary 与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告,且 merge 明确保持 `unsupported-command`。 +该命令经 backend-core 稳定 `code-queue` proxy 访问 D601 scheduler 的 `/api/runtime-preflight`,报告 scheduler/runner 环境里的 `GH_TOKEN`/`GITHUB_TOKEN` 覆盖、工具、Git worktree、GitHub egress、repo/issue/PR 只读探测和可选 push dry-run。需要复核 PR body/创建命令 guard 时追加 `--pr-create-dry-run --pr-create-dry-run-head `;该 guard 只执行 dry-run,不创建 PR。缺少 env token 时必须返回 `ok=false`、`runnerDisposition=infra-blocked` 和 `tokenCoverage.missing=["GH_TOKEN","GITHUB_TOKEN"]`,因为 provider dev container 只能转发 scheduler 已经拥有的 token。`--remote` 在 runner-like 环境里不再要求本地 `unidesk-backend-core`、`unidesk-database`、`baidu-netdisk-backend` 容器存在;这些本地 target stack 缺失只作为证据,不是最终主阻塞。若远程控制面可达,输出继续保留 ready preflight;若远程控制面不可达,结构化失败归类为 `failureKind=proxy-gap` / `degradedReason=remote-control-plane-unreachable`。输出中的 `prCapabilityContract` 用于指挥官快速审查 runner handoff:目标分支固定显示、push/PR create dry-run 标记为不写远端、系统 `gh` binary 与 UniDesk REST `bun scripts/cli.ts gh` 可用性分开报告,且 merge 明确保持 `unsupported-command`。 本地 runner preflight 示例: @@ -209,7 +224,7 @@ D601 artifact registry 的 systemd unit inactive 不等于 D601 全局离线。 每次新增 Code Queue 任务、补发 follow-up task,或处理一批完成未读任务后,都必须同步更新 GitHub 总看板 issue `#20` 的正文主表;如果发生实质态势变化,还要同步更新指挥简报 issue `#24` 的正文。看板更新应反映当前任务分布、关键 blocker 和粗略进度,不要只改聊天上下文或只改单个 issue,而让总态势图落后于实际调度状态。 -MiniMax 成本路由接入 #20 看板时,#20 的每个 Code Queue 任务行都应显式保留推荐或实际 runner/model、风险等级、验证证据和审阅状态。推荐列的语义只来自指挥官判断或 `codex submit --dry-run` 的 `routingRecommendation`,不能来自 worker 自评。MiniMax 任务在 #20 中必须先保持“待指挥官审阅”状态;只有指挥官核验 diff、commit、轻量验证和未越界后,才更新为已验收并执行 `codex read `。若 dry-run 给出 `commander-human-only` 或 GPT-5.5 recommendation,#20 不应把该任务降级给 MiniMax;若必须降级,必须在看板 progress 中写明人工接受的风险和额外审阅动作。 +成本路由接入 #20 看板时,#20 的每个 Code Queue 任务行都应显式保留推荐或实际 runner/model、风险等级、验证证据和审阅状态。推荐列的语义只来自指挥官判断或 `codex submit --dry-run` 的 `routingRecommendation`,不能来自 worker 自评。MiniMax 和 DeepSeek 任务在 #20 中必须先保持“待指挥官审阅”状态;只有指挥官核验 diff、commit、轻量验证和未越界后,才更新为已验收并执行 `codex read `。若 dry-run 给出 `commander-human-only` 或 GPT-5.5 recommendation,#20 不应把该任务降级给 MiniMax/DeepSeek;若必须降级,必须在看板 progress 中写明人工接受的风险和额外审阅动作。 ## 指挥工作流 diff --git a/scripts/code-queue-pr-preflight-contract-test.ts b/scripts/code-queue-pr-preflight-contract-test.ts index e8275287..0d529952 100644 --- a/scripts/code-queue-pr-preflight-contract-test.ts +++ b/scripts/code-queue-pr-preflight-contract-test.ts @@ -11,165 +11,285 @@ function asRecord(value: unknown): JsonRecord { return value as JsonRecord; } -function fixtureRuntimePreflight(tokenPresent: boolean, overrides: { systemGhPresent?: boolean } = {}): JsonRecord { - const systemGhPresent = overrides.systemGhPresent ?? true; +function localBackendCoreMissingFixture(): JsonRecord { return { - ok: tokenPresent, - checkedAt: "2026-05-20T00:00:00.000Z", - cwd: "/workspace/unidesk", - pid: 123, - ports: { - codex: { ok: true, commandPath: "/usr/local/bin/codex", version: "codex 0.128.0", errors: [] }, - opencode: { ok: true, commandPath: "/usr/local/bin/opencode", version: "opencode 1.14.48", errors: [] }, + ok: false, + failureKind: "target-stack-not-running", + degradedReason: "backend-core-container-missing", + runnerDisposition: "infra-blocked", + message: "backend-core/database target containers are not running; only verify-only containers were observed.", + targetStack: { + expectedContainers: ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"], + missingContainers: ["unidesk-backend-core", "unidesk-database", "baidu-netdisk-backend"], + verifyOnlyObserved: true, }, - pullRequestDelivery: { - ok: tokenPresent, + readOnlyCommands: [ + "bun scripts/cli.ts server status", + "bun scripts/cli.ts schedule list", + "bun scripts/cli.ts schedule runs --limit 20", + ], + authorizationRequiredForRecovery: ["restore runtime secret coverage", "start the target stack"], + }; +} + +function remoteControlPlaneResult(overrides: Partial = {}): JsonRecord { + return { + ok: true, + runnerDisposition: "ready", + failureKind: null, + degradedReason: null, + upstream: { ok: true, status: 200 }, + controlPlane: { + mode: "remote-frontend", + host: "74.48.78.17", + frontendUrl: "http://74.48.78.17:18081", + localBackendCoreMissing: true, + remoteFallbackUsed: true, + }, + preflight: { + ok: true, + runnerDisposition: "ready", + failureKind: null, + degradedReason: null, checkedAt: "2026-05-20T00:00:00.000Z", + runner: { + serviceId: "code-queue", + plane: "D601 k3s scheduler/runner", + queueScope: "all queues executed by the scheduler, including default", + cwd: "/workspace/unidesk", + pid: 123, + }, + tokenCoverage: { + ok: true, + source: "GH_TOKEN", + ghTokenPresent: true, + githubTokenPresent: false, + ghCredentialStorePresent: false, + runnerDisposition: "ready", + missing: [], + scope: "scheduler-runner-env", + }, + prCapabilityContract: { + targetBranch: "master", + tokenSource: "GH_TOKEN", + systemGhBinaryRequiredForWrites: false, + unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true, role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh", requiresSystemGhBinary: false }, + pushDryRun: { requested: false, ref: "refs/heads/probe/code-queue-pr-capability-dryrun", writesRemote: false, commandShape: "git push --dry-run origin HEAD:refs/heads/probe/code-queue-pr-capability-dryrun" }, + prCreateDryRun: { requested: false, headBranch: "feature/code-queue-pr-preflight", writesRemote: false, commandShape: "bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --base master --head feature/code-queue-pr-preflight --dry-run" }, + expectedPrHandoff: { + sourceBranch: "feature/code-queue-pr-preflight", + targetBranch: "master", + runnerCreatesPrAfterAuthorization: true, + commanderReviewsAndMerges: true, + preflightCreatesPr: false, + preflightMergesPr: false, + }, + unsupportedMergeBoundary: { + supported: false, + command: "bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk", + degradedReason: "unsupported-command", + runnerDisposition: "business-failed", + note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence.", + }, + }, + controlPlane: { + mode: "local-backend-core", + localBackendCoreMissing: false, + remoteFallbackUsed: false, + }, tools: { git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" }, - gh: { ok: systemGhPresent, path: systemGhPresent ? "/usr/bin/gh" : null, version: systemGhPresent ? "gh version 2.45.0" : null }, + gh: { ok: true, path: "/usr/bin/gh", version: "gh version 2.45.0" }, + systemGhBinary: { ok: true, path: "/usr/bin/gh", version: "gh version 2.45.0" }, hub: { ok: false, path: null, version: null }, jq: { ok: true, path: "/usr/bin/jq", version: "jq-1.7" }, ssh: { ok: true, path: "/usr/bin/ssh", version: "OpenSSH_9.6" }, curl: { ok: true, path: "/usr/bin/curl", version: "curl 8.5.0" }, + unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true, role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh", requiresSystemGhBinary: false }, }, - unideskGhCli: { - ok: true, - path: "/workspace/unidesk/scripts/cli.ts", - present: true, - }, - credentials: { - ghTokenPresent: tokenPresent, - githubTokenPresent: false, - ghHostPresent: false, - githubApiUrlPresent: false, - ghRepoPresent: false, - sshAuthSockPresent: false, - gitAskpassPresent: false, - ghHostsConfigPresent: false, - gitCredentialsPresent: false, - }, - githubContext: { - host: "github.com", - apiBaseUrl: "https://api.github.com", - repo: "pikasTech/unidesk", - issueProbeNumber: 20, - }, - egress: { - proxy: { selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", selectedProxyPort: "18789", selectedProxyHostResolvable: true }, - githubDefault: { command: "preflight", args: ["github-default-network"], ok: true, exitCode: 0, signal: null, error: null, stdout: "skipped", stderr: "" }, - apiDefault: { command: "preflight", args: ["github-api-default-network"], ok: true, exitCode: 0, signal: null, error: null, stdout: "skipped", stderr: "" }, - issueApi: null, + agentPorts: { + codex: { ok: true, commandPath: "/usr/local/bin/codex", version: "codex 0.128.0", errors: [] }, + opencode: { ok: true, commandPath: "/usr/local/bin/opencode", version: "opencode 1.14.48", errors: [] }, }, git: { insideWorktree: true, - branch: "master", + branch: "feature/code-queue-pr-preflight", head: "abc1234", - originMaster: "abc1234", + originMaster: "def5678", remoteOrigin: "git@github.com:pikasTech/unidesk.git", home: "/root", homeWritable: true, knownHostsPresent: true, privateKeyPresent: true, }, - prCreateDryRun: tokenPresent ? { command: "sh", args: ["-lc", "bun scripts/cli.ts gh pr create --dry-run"], ok: true, exitCode: 0, signal: null, error: null, stdout: "{\"ok\":true,\"data\":{\"dryRun\":true}}", stderr: "" } : undefined, - limitations: tokenPresent ? [] : ["GH_TOKEN/GITHUB_TOKEN is not present; gh cannot create PRs unless another gh credential store is mounted"], - risks: systemGhPresent ? [] : ["system gh binary is missing; UniDesk REST gh CLI remains the supported PR create/comment path when scripts/cli.ts and GH_TOKEN/GITHUB_TOKEN are available"], + githubContext: { + host: "github.com", + apiBaseUrl: "https://api.github.com", + repo: "pikasTech/unidesk", + issueProbeNumber: 35, + }, + egress: { + proxy: { + selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local", + selectedProxyPort: "18789", + selectedProxyHostResolvable: true, + }, + githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + issueApi: null, + }, + remote: { + gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: true, exitCode: 0, signal: null, error: null, stdout: "abc1234\trefs/heads/master\n", stderr: "" }, + gitHttpsLsRemote: null, + githubSshAuthenticated: true, + ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + ghRepoView: { command: "gh", args: ["repo", "view", "pikasTech/unidesk"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + ghIssueView: { command: "gh", args: ["issue", "view", "35"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + ghPrReadOnly: { command: "gh", args: ["pr", "list"], ok: true, exitCode: 0, signal: null, error: null, stdout: "", stderr: "" }, + }, + pushDryRun: null, + prCreateDryRun: null, + limitations: [], + risks: [], + runnerDisposition: "ready", + recoveryHint: "Runner PR workflow has env-token coverage for the scheduler.", + commands: { + local: "bun scripts/cli.ts gh auth status --repo pikasTech/unidesk", + runner: "bun scripts/cli.ts codex pr-preflight --remote", + runnerPushDryRun: "bun scripts/cli.ts codex pr-preflight --remote --push-dry-run --push-dry-run-ref refs/heads/probe/code-queue-pr-capability", + runnerPrCreateDryRun: "bun scripts/cli.ts codex pr-preflight --remote --pr-create-dry-run --pr-create-dry-run-head ", + rawProxy: "bun scripts/cli.ts microservice proxy code-queue /api/runtime-preflight?remote=1 --raw", + }, }, + ...overrides, }; } -function fixtureResponse(tokenPresent: boolean, overrides: { systemGhPresent?: boolean } = {}): JsonRecord { - return { - ok: true, - status: 200, - body: { - ok: true, - runtimePreflight: fixtureRuntimePreflight(tokenPresent, overrides), +async function main(): Promise { + let observedLocalPath = ""; + const remoteFallback = await codexPrPreflightQueryForTest(["--remote", "--issue", "35"], { + config: { + project: { name: "unidesk", timezone: "Etc/UTC" }, + runtime: { typescript: "bun", bunVersion: "1.3.13" }, + network: { + host: "0.0.0.0", + publicHost: "74.48.78.17", + core: { port: 18080, containerPort: 8080 }, + frontend: { port: 18081, containerPort: 8080 }, + devFrontend: { port: 18083, containerPort: 8080 }, + database: { port: 15432, containerPort: 5432 }, + providerIngress: { port: 18082, containerPort: 8081 }, + }, + database: { user: "unidesk", password: "unidesk_dev_password", name: "unidesk", volume: "unidesk_pgdata_10gb", volumeSize: "15GB" }, + providerGateway: { + id: "main-server", + name: "Main Server Provider", + token: "unidesk-dev-token-change-me", + labels: { host: "main-server", role: "self-provider", docker: true }, + heartbeatIntervalMs: 15000, + reconnectBaseMs: 1000, + reconnectMaxMs: 30000, + metrics: { diskPath: "/" }, + upgrade: { hostProjectRoot: "/root/unidesk", workspacePath: "/workspace", composeFile: "docker-compose.yml", composeEnvFile: ".state/docker-compose.env", composeProject: "unidesk", service: "provider-gateway", runnerImage: "unidesk_provider-gateway" }, + }, + docker: { composeFile: "docker-compose.yml", projectName: "unidesk" }, + microservices: [], + paths: { stateDir: ".state", logsDir: "logs", docsReferenceDir: "docs/reference" }, + sshForwarding: { mode: "ws", keyDir: "/root/.ssh", host: "main-server", port: 22, user: "root" }, + auth: { username: "admin", password: "Liang6516.", sessionSecret: "secret", sessionTtlSeconds: 86400 }, }, - }; -} - -export function runCodeQueuePrPreflightContract(): JsonRecord { - let observedPath = ""; - const missing = codexPrPreflightQueryForTest(["--remote", "--issue", "35"], (path) => { - observedPath = path; - return fixtureResponse(false); + coreFetch: (path) => { + observedLocalPath = path; + return localBackendCoreMissingFixture(); + }, + remoteMainServerPrPreflight: () => remoteControlPlaneResult({ + controlPlane: { + mode: "remote-frontend", + host: "74.48.78.17", + frontendUrl: "http://74.48.78.17:18081", + localBackendCoreMissing: true, + remoteFallbackUsed: true, + }, + failureKind: null, + degradedReason: null, + }), }); - assertCondition( - observedPath === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&issue=35", - "PR preflight should route to the stable code-queue runtime preflight path", - { observedPath }, - ); - assertCondition(asRecord(missing).ok === false, "missing token preflight should set top-level ok=false", missing); - assertCondition(asRecord(missing).runnerDisposition === "infra-blocked", "missing token preflight should expose root runnerDisposition", missing); - const missingPreflight = asRecord(asRecord(missing).preflight); - assertCondition(missingPreflight.ok === false, "missing token preflight should fail", missingPreflight); - assertCondition(missingPreflight.runnerDisposition === "infra-blocked", "missing token must be infra-blocked", missingPreflight); - const missingTokenCoverage = asRecord(missingPreflight.tokenCoverage); - assertCondition(missingTokenCoverage.ok === false, "tokenCoverage should fail when no env token is present", missingTokenCoverage); - assertCondition(Array.isArray(missingTokenCoverage.missing) && missingTokenCoverage.missing.includes("GH_TOKEN") && missingTokenCoverage.missing.includes("GITHUB_TOKEN"), "tokenCoverage should name both accepted env keys", missingTokenCoverage); - assertCondition(!JSON.stringify(missing).includes("contract-token"), "preflight output must not leak token values", missing); + assertCondition(observedLocalPath === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&issue=35", "runner-like local path should stay on the stable proxy", { observedLocalPath }); + const fallback = asRecord(remoteFallback); + assertCondition(fallback.ok === true, "remote fallback should succeed", fallback); + assertCondition(fallback.runnerDisposition === "ready", "remote fallback should stay ready", fallback); + assertCondition(fallback.controlPlane && asRecord(fallback.controlPlane).remoteFallbackUsed === true, "remote fallback should be marked", fallback.controlPlane); + assertCondition(fallback.failureKind === null, "remote fallback should not invent a failure kind when remote control plane is healthy", fallback); + const fallbackPreflight = asRecord(fallback.preflight); + assertCondition(fallbackPreflight.ok === true, "remote fallback preflight should stay ready", fallbackPreflight); + assertCondition(asRecord(fallbackPreflight.tokenCoverage).source === "GH_TOKEN", "token source should be GH_TOKEN", fallbackPreflight.tokenCoverage); + assertCondition(asRecord(fallbackPreflight.prCapabilityContract).targetBranch === "master", "target branch should stay master", fallbackPreflight.prCapabilityContract); - const ready = codexPrPreflightQueryForTest(["--remote", "--push-dry-run", "--push-dry-run-ref", "refs/heads/probe/test"], (path) => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&pushDryRun=1&pushDryRunRef=refs%2Fheads%2Fprobe%2Ftest", "push dry-run options should map to query string", { path }); - return fixtureResponse(true); + const authMissing = await codexPrPreflightQueryForTest(["--remote"], { + config: null, + coreFetch: () => localBackendCoreMissingFixture(), }); - const readyPreflight = asRecord(asRecord(ready).preflight); - assertCondition(asRecord(ready).ok === true, "token-ready preflight should set top-level ok=true", ready); - assertCondition(asRecord(ready).runnerDisposition === "ready", "token-ready preflight should expose root runnerDisposition", ready); - assertCondition(readyPreflight.ok === true, "token-ready preflight should pass fixture", readyPreflight); - assertCondition(readyPreflight.runnerDisposition === "ready", "ready preflight should report ready disposition", readyPreflight); - const readyTokenCoverage = asRecord(readyPreflight.tokenCoverage); - assertCondition(readyTokenCoverage.source === "GH_TOKEN", "ready token source should be redacted to key name only", readyTokenCoverage); - const readyContract = asRecord(readyPreflight.prCapabilityContract); - assertCondition(readyContract.targetBranch === "master", "PR preflight should expose target branch", readyContract); - const readyPushDryRun = asRecord(readyContract.pushDryRun); - assertCondition(readyPushDryRun.requested === true && readyPushDryRun.writesRemote === false, "push dry-run contract should be explicit and non-writing", readyPushDryRun); - const readyHandoff = asRecord(readyContract.expectedPrHandoff); - assertCondition(readyHandoff.targetBranch === "master" && readyHandoff.commanderReviewsAndMerges === true && readyHandoff.preflightCreatesPr === false, "PR handoff should stop at runner PR creation evidence", readyHandoff); - const readyMergeBoundary = asRecord(readyContract.unsupportedMergeBoundary); - assertCondition(readyMergeBoundary.degradedReason === "unsupported-command" && readyMergeBoundary.runnerDisposition === "business-failed", "merge boundary should remain unsupported", readyMergeBoundary); - const readyCliStatus = asRecord(readyContract.unideskGhCli); - assertCondition(readyCliStatus.ok === true && readyCliStatus.requiresSystemGhBinary === false, "UniDesk gh CLI availability should be separate from system gh binary", readyCliStatus); + const remoteControlPlaneMissingRecord = asRecord(authMissing); + assertCondition(remoteControlPlaneMissingRecord.ok === false, "missing control plane should fail", remoteControlPlaneMissingRecord); + assertCondition(remoteControlPlaneMissingRecord.failureKind === "proxy-gap", "missing control plane should classify as proxy-gap", remoteControlPlaneMissingRecord); + assertCondition(remoteControlPlaneMissingRecord.degradedReason === "remote-control-plane-unreachable", "missing control plane should classify as remote-control-plane-unreachable", remoteControlPlaneMissingRecord); + assertCondition(asRecord(remoteControlPlaneMissingRecord.controlPlane).localBackendCoreMissing === true, "local backend-core absence should remain evidence only", remoteControlPlaneMissingRecord.controlPlane); - const prCreateDryRun = codexPrPreflightQueryForTest(["--remote", "--pr-create-dry-run", "--pr-create-dry-run-head", "codequeue/pr-probe"], (path) => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1&prCreateDryRun=1&prCreateDryRunHead=codequeue%2Fpr-probe", "PR create dry-run options should map to query string", { path }); - return fixtureResponse(true); + const directAuthMissing = remoteControlPlaneResult({ + ok: false, + failureKind: "auth-missing", + degradedReason: "GH_TOKEN/GITHUB_TOKEN missing", + runnerDisposition: "infra-blocked", + message: "GH_TOKEN/GITHUB_TOKEN missing in remote control plane", + tokenCoverage: { + ok: false, + source: null, + ghTokenPresent: false, + githubTokenPresent: false, + ghCredentialStorePresent: false, + runnerDisposition: "infra-blocked", + missing: ["GH_TOKEN", "GITHUB_TOKEN"], + scope: "scheduler-runner-env", + }, + prCapabilityContract: { + targetBranch: "master", + tokenSource: null, + systemGhBinaryRequiredForWrites: false, + unideskGhCli: { ok: true, path: "/workspace/unidesk/scripts/cli.ts", present: true, role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh", requiresSystemGhBinary: false }, + pushDryRun: { requested: false, ref: "refs/heads/probe/code-queue-pr-capability-dryrun", writesRemote: false, commandShape: "git push --dry-run origin HEAD:refs/heads/probe/code-queue-pr-capability-dryrun" }, + prCreateDryRun: { requested: false, headBranch: "feature/code-queue-pr-preflight", writesRemote: false, commandShape: "bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --base master --head feature/code-queue-pr-preflight --dry-run" }, + expectedPrHandoff: { sourceBranch: "feature/code-queue-pr-preflight", targetBranch: "master", runnerCreatesPrAfterAuthorization: true, commanderReviewsAndMerges: true, preflightCreatesPr: false, preflightMergesPr: false }, + unsupportedMergeBoundary: { supported: false, command: "bun scripts/cli.ts gh pr merge --repo pikasTech/unidesk", degradedReason: "unsupported-command", runnerDisposition: "business-failed", note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence." }, + }, }); - const prCreateDryRunPreflight = asRecord(asRecord(prCreateDryRun).preflight); - const prCreateDryRunProbe = asRecord(prCreateDryRunPreflight.prCreateDryRun); - assertCondition(prCreateDryRunProbe.ok === true, "PR create dry-run probe should be compacted", prCreateDryRunProbe); - const prCreateDryRunContract = asRecord(prCreateDryRunPreflight.prCapabilityContract); - const prCreateDryRunSummary = asRecord(prCreateDryRunContract.prCreateDryRun); - assertCondition(prCreateDryRunSummary.requested === true && prCreateDryRunSummary.headBranch === "codequeue/pr-probe" && prCreateDryRunSummary.writesRemote === false, "PR create dry-run contract should be explicit and non-writing", prCreateDryRunSummary); + const directAuthMissingRecord = asRecord(directAuthMissing); + assertCondition(directAuthMissingRecord.ok === false, "auth-missing remote result should fail", directAuthMissingRecord); + assertCondition(directAuthMissingRecord.failureKind === "auth-missing", "missing token should classify as auth-missing", directAuthMissingRecord); + assertCondition(directAuthMissingRecord.degradedReason === "GH_TOKEN/GITHUB_TOKEN missing", "auth missing should state token gap", directAuthMissingRecord); - const noSystemGh = codexPrPreflightQueryForTest(["--remote"], (path) => { - assertCondition(path === "/api/microservices/code-queue/proxy/api/runtime-preflight?remote=1", "system gh missing fixture should use remote preflight path", { path }); - return fixtureResponse(true, { systemGhPresent: false }); + const gitRemoteGap = remoteControlPlaneResult({ + ok: false, + failureKind: "git-remote-gap", + degradedReason: "git remote probe failed", + runnerDisposition: "infra-blocked", + message: "git ls-remote probe failed", }); - const noSystemGhPreflight = asRecord(asRecord(noSystemGh).preflight); - assertCondition(noSystemGhPreflight.ok === true, "missing system gh should not block UniDesk REST PR CLI when token and scripts/cli.ts exist", noSystemGhPreflight); - const noSystemGhTools = asRecord(noSystemGhPreflight.tools); - assertCondition(asRecord(noSystemGhTools.systemGhBinary).ok === false, "system gh binary status should be explicit", noSystemGhTools); - assertCondition(asRecord(noSystemGhTools.unideskGhCli).ok === true, "UniDesk gh CLI should stay available when system gh is missing", noSystemGhTools); + const gitRemoteGapRecord = asRecord(gitRemoteGap); + assertCondition(gitRemoteGapRecord.failureKind === "git-remote-gap", "git probe failures should stay structured", gitRemoteGapRecord); - return { + process.stdout.write(`${JSON.stringify({ ok: true, checks: [ - "stable runtime-preflight proxy path", - "missing GitHub token is infra-blocked", - "token key names are reported without values", - "fake token source reports only the env key", - "system gh binary and UniDesk REST gh CLI are distinct", - "push dry-run options are forwarded", - "PR create dry-run options are forwarded", - "dry-run push and PR create are marked non-writing", - "expected PR handoff and unsupported merge boundary are explicit", + "runner-like local target-stack absence does not block remote fallback", + "remote control plane fallback preserves ready preflight", + "missing remote control plane returns proxy-gap", + "auth missing returns auth-missing", + "git remote failures return git-remote-gap", ], - }; + observedLocalPath, + }, null, 2)}\n`); } if (import.meta.main) { - process.stdout.write(`${JSON.stringify(runCodeQueuePrPreflightContract(), null, 2)}\n`); + await main(); } diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index aedc9a73..45030d4f 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -1,4 +1,5 @@ import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { runCommand } from "./command"; import { type UniDeskConfig, repoRoot, rootPath } from "./config"; import { coreInternalFetch } from "./microservices"; import { previewJson } from "./preview"; @@ -220,6 +221,14 @@ interface CodexPrPreflightOptions { full: boolean; } +type CodeQueuePrPreflightFailureKind = "auth-missing" | "proxy-gap" | "git-remote-gap" | "target-stack-not-running"; + +interface CodeQueuePrPreflightTransport { + config?: UniDeskConfig | null; + coreFetch?: CodexResponseFetcher; + remoteMainServerPrPreflight?: (optionArgs: string[], config: UniDeskConfig | null) => unknown; +} + type CodexRequestInit = { method?: string; body?: unknown }; type CodexResponseFetcher = (path: string, init?: CodexRequestInit) => unknown; type AsyncCodexResponseFetcher = (path: string, init?: CodexRequestInit) => Promise; @@ -383,6 +392,16 @@ function upstreamError(response: unknown): string { return `${status}: ${JSON.stringify(response).slice(0, 1200)}`; } +function parseJsonRecord(text: string): Record | null { + if (text.trim().length === 0) return null; + try { + const parsed = JSON.parse(text) as unknown; + return asRecord(parsed); + } catch { + return null; + } +} + function unwrapCodexResponse(response: unknown): { upstream: { ok: unknown; status: unknown }; body: Record } { const record = asRecord(response); if (record?.ok !== true) throw new Error(upstreamError(response)); @@ -2468,6 +2487,11 @@ function compactPrRuntimePreflight(preflight: Record, options: note: "UniDesk CLI intentionally does not merge PRs in this phase; runner handoff stops at PR creation and evidence.", }, }, + controlPlane: { + mode: "local-backend-core", + localBackendCoreMissing: false, + remoteFallbackUsed: false, + }, tools: { git: compactToolStatus(tools.git), gh: compactToolStatus(tools.gh), @@ -2538,7 +2562,211 @@ function compactPrRuntimePreflight(preflight: Record, options: return result; } -function codeQueuePrPreflight(optionArgs: string[] = [], fetcher: CodexResponseFetcher = coreInternalFetch): unknown { +function queryRemoteMainServerPrPreflight(optionArgs: string[], config: UniDeskConfig): unknown { + const command = ["bun", "scripts/cli.ts", "--main-server-ip", config.network.publicHost, "codex", "pr-preflight", ...optionArgs]; + const result = runCommand(command, repoRoot, { timeoutMs: 120_000 }); + const parsed = parseJsonRecord(result.stdout); + if (parsed !== null) { + const data = asRecord(parsed.data); + const dataResult = asRecord(data?.result); + if (dataResult !== null) return dataResult; + if (data !== null) return data; + const error = asRecord(parsed.error); + if (error !== null) { + const message = typeof error.message === "string" && error.message.length > 0 + ? error.message + : result.stderr.trim() || result.stdout.trim() || "remote control plane unreachable"; + return { + ok: false, + runnerDisposition: "infra-blocked", + failureKind: "proxy-gap", + degradedReason: "remote-control-plane-unreachable", + message, + controlPlane: { + mode: "remote-frontend", + host: config.network.publicHost, + frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`, + localBackendCoreMissing: false, + remoteFallbackUsed: true, + }, + observed: { + exitCode: result.exitCode, + stdoutTail: result.stdout.slice(-2000), + stderrTail: result.stderr.slice(-2000), + }, + commands: { + retry: `bun scripts/cli.ts --main-server-ip ${config.network.publicHost} codex pr-preflight --remote`, + }, + }; + } + } + const message = result.stderr.trim() || result.stdout.trim() || `remote control plane unreachable: exitCode=${result.exitCode ?? "null"}`; + return { + ok: false, + runnerDisposition: "infra-blocked", + failureKind: "proxy-gap", + degradedReason: "remote-control-plane-unreachable", + message, + controlPlane: { + mode: "remote-frontend", + host: config.network.publicHost, + frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`, + localBackendCoreMissing: false, + remoteFallbackUsed: true, + }, + observed: { + exitCode: result.exitCode, + stdoutTail: result.stdout.slice(-2000), + stderrTail: result.stderr.slice(-2000), + }, + commands: { + retry: `bun scripts/cli.ts --main-server-ip ${config.network.publicHost} codex pr-preflight --remote`, + }, + }; +} + +function codeQueuePrPreflight(optionArgs: string[] = [], transport: CodeQueuePrPreflightTransport = {}): unknown { + const options = parsePrPreflightOptions(optionArgs); + const config = transport.config ?? null; + const fetcher = transport.coreFetch ?? coreInternalFetch; + const path = codeQueueProxyPath(`/api/runtime-preflight${queryString({ + remote: options.remote ? 1 : undefined, + pushDryRun: options.pushDryRun ? 1 : undefined, + pushDryRunRef: options.pushDryRunRef, + prCreateDryRun: options.prCreateDryRun ? 1 : undefined, + prCreateDryRunHead: options.prCreateDryRunHead, + issue: options.issueNumber, + })}`); + const localResponse = fetcher(path); + const localRecord = asRecord(localResponse); + const localTargetStackMissing = localRecord?.ok === false + && localRecord.failureKind === "target-stack-not-running" + && localRecord.degradedReason === "backend-core-container-missing"; + const remoteMainServerPrPreflight = transport.remoteMainServerPrPreflight + ?? (config === null ? null : (args: string[], _config: UniDeskConfig | null) => queryRemoteMainServerPrPreflight(args, config)); + if (options.remote && localTargetStackMissing && remoteMainServerPrPreflight !== null) { + const remoteResponse = remoteMainServerPrPreflight(optionArgs, config); + const remoteRecord = asRecord(remoteResponse); + if (remoteRecord !== null) { + if (remoteRecord.ok === false) { + return { + ...remoteRecord, + controlPlane: { + ...(asRecord(remoteRecord.controlPlane) ?? {}), + mode: "remote-frontend", + host: config?.network.publicHost ?? null, + frontendUrl: config === null ? null : `http://${config.network.publicHost}:${config.network.frontend.port}`, + localBackendCoreMissing: true, + remoteFallbackUsed: true, + }, + localObservation: localRecord, + remoteObservation: remoteRecord, + }; + } + return { + ...remoteRecord, + controlPlane: { + ...(asRecord(remoteRecord.controlPlane) ?? {}), + mode: "remote-frontend", + host: config?.network.publicHost ?? null, + frontendUrl: config === null ? null : `http://${config.network.publicHost}:${config.network.frontend.port}`, + localBackendCoreMissing: true, + remoteFallbackUsed: true, + }, + localObservation: localRecord, + remoteObservation: remoteRecord, + }; + } + } + if (localRecord?.ok !== true) { + if (options.remote && localTargetStackMissing) { + const failureKind: CodeQueuePrPreflightFailureKind = "proxy-gap"; + const degradedReason = "remote-control-plane-unreachable"; + return { + ...(localRecord ?? {}), + ok: false, + runnerDisposition: "infra-blocked", + failureKind, + degradedReason, + message: "remote control plane unreachable; local backend-core target-stack absence is evidence only", + controlPlane: { + mode: "local-backend-core", + localBackendCoreMissing: true, + remoteFallbackUsed: false, + }, + commands: { + retry: config !== null + ? `bun scripts/cli.ts --main-server-ip ${config.network.publicHost} codex pr-preflight --remote` + : "bun scripts/cli.ts codex pr-preflight --remote", + local: "bun scripts/cli.ts microservice proxy code-queue /api/runtime-preflight --raw", + }, + }; + } + return { + ...(localRecord ?? {}), + ok: false, + runnerDisposition: localRecord?.runnerDisposition ?? "infra-blocked", + failureKind: (localRecord?.failureKind as CodeQueuePrPreflightFailureKind | undefined) ?? "proxy-gap", + degradedReason: localRecord?.degradedReason ?? "backend-core-proxy-unavailable", + message: localRecord?.message ?? localRecord?.stderrTail ?? localRecord?.stdoutTail ?? "Code Queue runtime preflight could not be observed", + controlPlane: { + mode: "local-backend-core", + localBackendCoreMissing: localTargetStackMissing, + remoteFallbackUsed: false, + }, + commands: { + retry: config !== null + ? `bun scripts/cli.ts --main-server-ip ${config.network.publicHost} codex pr-preflight --remote` + : "bun scripts/cli.ts codex pr-preflight --remote", + local: "bun scripts/cli.ts microservice proxy code-queue /api/runtime-preflight --raw", + }, + }; + } + const response = asRecord(localRecord); + if (response?.ok !== true) { + throw new Error(upstreamError(localRecord)); + } + const body = asRecord(response.body); + const preflight = asRecord(body?.runtimePreflight); + if (preflight === null) { + return { + ok: false, + runnerDisposition: "infra-blocked", + failureKind: "proxy-gap", + degradedReason: "runtime-preflight-missing", + message: "Code Queue runtime-preflight response did not include runtimePreflight", + controlPlane: { + mode: "local-backend-core", + localBackendCoreMissing: false, + remoteFallbackUsed: false, + }, + upstream: { ok: response.ok, status: response.status }, + commands: { + retry: "bun scripts/cli.ts codex pr-preflight --remote", + }, + }; + } + const compact = compactPrRuntimePreflight(preflight, options); + return { + ok: compact.ok, + runnerDisposition: compact.runnerDisposition, + failureKind: compact.failureKind ?? null, + degradedReason: compact.degradedReason ?? null, + upstream: { ok: response.ok, status: response.status }, + controlPlane: { + mode: "local-backend-core", + localBackendCoreMissing: false, + remoteFallbackUsed: false, + }, + preflight: compact, + }; +} + +export function codexPrPreflightQueryForTest(optionArgs: string[], transport: CodeQueuePrPreflightTransport = {}): unknown { + return codeQueuePrPreflight(optionArgs, transport); +} + +export async function codexPrPreflightQueryAsync(optionArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise { const options = parsePrPreflightOptions(optionArgs); const path = codeQueueProxyPath(`/api/runtime-preflight${queryString({ remote: options.remote ? 1 : undefined, @@ -2548,22 +2776,44 @@ function codeQueuePrPreflight(optionArgs: string[] = [], fetcher: CodexResponseF prCreateDryRunHead: options.prCreateDryRunHead, issue: options.issueNumber, })}`); - const response = unwrapCodexResponse(fetcher(path)); - const preflight = asRecord(response.body.runtimePreflight); - if (preflight === null) throw new Error("Code Queue runtime-preflight response did not include runtimePreflight"); + const response = asRecord(await fetcher(path)); + if (response?.ok !== true) throw new Error(upstreamError(response)); + const body = asRecord(response.body); + const preflight = asRecord(body?.runtimePreflight); + if (preflight === null) { + return { + ok: false, + runnerDisposition: "infra-blocked", + failureKind: "proxy-gap", + degradedReason: "runtime-preflight-missing", + message: "Code Queue runtime-preflight response did not include runtimePreflight", + controlPlane: { + mode: "remote-frontend", + localBackendCoreMissing: false, + remoteFallbackUsed: false, + }, + upstream: { ok: response.ok, status: response.status }, + commands: { + retry: "bun scripts/cli.ts codex pr-preflight --remote", + }, + }; + } const compact = compactPrRuntimePreflight(preflight, options); return { ok: compact.ok, runnerDisposition: compact.runnerDisposition, - upstream: response.upstream, + failureKind: compact.failureKind ?? null, + degradedReason: compact.degradedReason ?? null, + upstream: { ok: response.ok, status: response.status }, + controlPlane: { + mode: "remote-frontend", + localBackendCoreMissing: false, + remoteFallbackUsed: false, + }, preflight: compact, }; } -export function codexPrPreflightQueryForTest(optionArgs: string[], fetcher: CodexResponseFetcher): unknown { - return codeQueuePrPreflight(optionArgs, fetcher); -} - export function codexSubmitRoutingRecommendationForTest(prompt: string, model?: string): SubmitRoutingRecommendation { return submitRoutingRecommendation({ prompt, @@ -2680,7 +2930,7 @@ function codexSteerTask(taskId: string, args: string[], fetcher: CodexResponseFe }; } -export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]): Promise { +export async function runCodeQueueCommand(config: UniDeskConfig, args: string[]): Promise { const [action = "task", taskIdArg] = args; if (action === "submit" || action === "enqueue") { return codexSubmitTask(args.slice(1)); @@ -2696,7 +2946,7 @@ export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[] assertKnownOptions(args.slice(1), {}, `codex ${action}`); return codeQueueDevReady(); } - if (action === "pr-preflight" || action === "runtime-preflight") return codeQueuePrPreflight(args.slice(1)); + if (action === "pr-preflight" || action === "runtime-preflight") return codeQueuePrPreflight(args.slice(1), { config }); if (action === "output") { const taskId = requireTaskId(taskIdArg, "codex output"); return codexOutputQuery(taskId, args.slice(2)); diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index 66f7a3a6..6725a106 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -4,7 +4,7 @@ import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug"; import { summarizeMicroserviceProxyResponse } from "./microservices"; import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf"; import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh"; -import { codexJudgeQueryAsync, codexOutputQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync } from "./code-queue"; +import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync } from "./code-queue"; import { runDecisionCenterCommandAsync } from "./decision-center"; import { artifactRegistryReadonlyResultFromCommand, @@ -763,8 +763,8 @@ function dispatchedTaskShape(remoteCommandShape: string): string { async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise { const action = args[1] ?? "task"; - if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "output" && action !== "judge") { - throw new Error("remote codex command must be: codex task , codex tasks, codex output , or codex judge --attempt N"); + if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "output" && action !== "judge" && action !== "pr-preflight" && action !== "runtime-preflight") { + throw new Error("remote codex command must be: codex task , codex tasks, codex output , codex judge --attempt N, or codex pr-preflight [--remote]"); } const taskId = args[2]; if ((action === "task" || action === "summary" || action === "show" || action === "output" || action === "judge") && (taskId === undefined || taskId.length === 0)) { @@ -786,6 +786,8 @@ async function remoteCodeQueue(session: FrontendSession, args: string[]): Promis ? await codexTasksQueryAsync(args.slice(1), fetcher) : action === "output" ? await codexOutputQueryAsync(requiredTaskId, args.slice(3), fetcher) + : action === "pr-preflight" || action === "runtime-preflight" + ? await codexPrPreflightQueryAsync(args.slice(1), fetcher) : action === "judge" ? await codexJudgeQueryAsync(requiredTaskId, args.slice(3), fetcher) : await codexTaskQueryAsync(requiredTaskId, args.slice(3), fetcher), @@ -854,7 +856,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe emitRemoteJson(name, { transport: "frontend", baseUrl: session.baseUrl, - commands: ["debug health", "debug dispatch", "debug task", "ssh ", "ssh skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "microservice list", "microservice status ", "microservice health ", "microservice diagnostics ", "microservice tunnel-self-test ", "microservice proxy ", "decision upload ", "decision list", "decision show ", "codex task ", "codex tasks", "codex judge --attempt N", "network perf"], + commands: ["debug health", "debug dispatch", "debug task", "ssh ", "ssh skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status ", "microservice health ", "microservice diagnostics ", "microservice tunnel-self-test ", "microservice proxy ", "decision upload ", "decision list", "decision show ", "codex task ", "codex tasks", "codex judge --attempt N", "codex pr-preflight [--remote]", "network perf"], }); return 0; }