From 12a440f9d3056f3408e90d320dae0176c2d1ae8c Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 06:03:06 +0000 Subject: [PATCH] fix: tighten code queue pr preflight contract --- docs/reference/cli.md | 2 +- docs/reference/code-queue-supervision.md | 2 +- docs/reference/codex-deploy.md | 2 +- .../code-queue-pr-preflight-contract-test.ts | 45 ++++++++++++++-- scripts/src/code-queue.ts | 52 +++++++++++++++++++ .../code-queue/src/runtime-preflight.ts | 21 +++++++- 6 files changed, 114 insertions(+), 10 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ce25a61d..28b16c06 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -41,7 +41,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 禁止/证据要求等 guard 状态;该建议只用于指挥官 preflight,不会改写 payload,也不假设生产 MiniMax 可用。提交确认和 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`。`--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` 边界。`--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 d59fc8cd..214ba90e 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -136,7 +136,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。 +该命令经 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`。 本地 runner preflight 示例: diff --git a/docs/reference/codex-deploy.md b/docs/reference/codex-deploy.md index 71f7ac9d..0535076e 100644 --- a/docs/reference/codex-deploy.md +++ b/docs/reference/codex-deploy.md @@ -70,7 +70,7 @@ D601 原生 k3s 的人工诊断必须显式使用 host kubeconfig:`KUBECONFIG= Code Queue worker 可以在任务明确要求审查型交付时创建 Pull Request。PR 交付不是默认出口;默认集成仍遵循项目当前 master-only 规则,直到具体任务或指挥官要求改为 PR。PR 型任务必须从最新目标线创建短生命周期分支,报告源分支、目标分支、PR URL、关联 issue、验证证据和未完成风险;分支命名应使用 `probe/`、`code-queue/` 或其他明确任务前缀,禁止把隐藏分支当成长期交付状态。 -Code Queue runtime 提供 `/api/runtime-preflight` 作为 PR 能力探测入口;CLI 稳定入口是 `bun scripts/cli.ts 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]`。默认请求只检查本地工具、凭证可见性、Git worktree、HOME、known_hosts、agent port 和 proxy DNS;`--remote` 会增加 GitHub 网络、issue API、SSH/HTTPS `git ls-remote`、GitHub SSH、`gh auth status`、`gh repo view`、`gh issue read/view` 和只读 `gh pr list` 探测;`--push-dry-run` 会额外执行 `git push --dry-run`,验证远端写权限但不创建分支;`--pr-create-dry-run` 会在 runner 内生成受控 PR body 并执行 `scripts/cli.ts gh pr create --dry-run`,只证明 PR body guard 和命令形态可用,不 POST GitHub。探测输出只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在,不得输出 token 内容。backend-core 的稳定 `code-queue` proxy 必须把 `/api/runtime-preflight` 路由到 D601 scheduler,而不是主 server `code-queue-mgr`,因为 token 和 PR runner 能力属于执行面环境。 +Code Queue runtime 提供 `/api/runtime-preflight` 作为 PR 能力探测入口;CLI 稳定入口是 `bun scripts/cli.ts 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]`。默认请求只检查本地工具、凭证可见性、Git worktree、HOME、known_hosts、agent port 和 proxy DNS;`--remote` 会增加 GitHub 网络、issue API、SSH/HTTPS `git ls-remote`、GitHub SSH、`gh auth status`、`gh repo view`、`gh issue read/view` 和只读 `gh pr list` 探测;`--push-dry-run` 会额外执行 `git push --dry-run`,验证远端写权限但不创建分支;`--pr-create-dry-run` 会在 runner 内生成受控 PR body 并执行 `scripts/cli.ts gh pr create --dry-run`,只证明 PR body guard 和命令形态可用,不 POST GitHub。探测输出只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在,不得输出 token 内容,并通过 `prCapabilityContract` 明确 token source、target branch、expected PR handoff、dry-run 不写远端和 merge unsupported 边界。backend-core 的稳定 `code-queue` proxy 必须把 `/api/runtime-preflight` 路由到 D601 scheduler,而不是主 server `code-queue-mgr`,因为 token 和 PR runner 能力属于执行面环境。 PR 创建依赖以下最小运行时能力: diff --git a/scripts/code-queue-pr-preflight-contract-test.ts b/scripts/code-queue-pr-preflight-contract-test.ts index bc8a7c64..e8275287 100644 --- a/scripts/code-queue-pr-preflight-contract-test.ts +++ b/scripts/code-queue-pr-preflight-contract-test.ts @@ -11,7 +11,8 @@ function asRecord(value: unknown): JsonRecord { return value as JsonRecord; } -function fixtureRuntimePreflight(tokenPresent: boolean): JsonRecord { +function fixtureRuntimePreflight(tokenPresent: boolean, overrides: { systemGhPresent?: boolean } = {}): JsonRecord { + const systemGhPresent = overrides.systemGhPresent ?? true; return { ok: tokenPresent, checkedAt: "2026-05-20T00:00:00.000Z", @@ -26,12 +27,17 @@ function fixtureRuntimePreflight(tokenPresent: boolean): JsonRecord { checkedAt: "2026-05-20T00:00:00.000Z", tools: { git: { ok: true, path: "/usr/bin/git", version: "git version 2.43.0" }, - gh: { ok: true, path: "/usr/bin/gh", version: "gh version 2.45.0" }, + gh: { ok: systemGhPresent, path: systemGhPresent ? "/usr/bin/gh" : null, version: systemGhPresent ? "gh version 2.45.0" : null }, 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, + }, credentials: { ghTokenPresent: tokenPresent, githubTokenPresent: false, @@ -68,18 +74,18 @@ function fixtureRuntimePreflight(tokenPresent: boolean): JsonRecord { }, 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: [], + 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"], }, }; } -function fixtureResponse(tokenPresent: boolean): JsonRecord { +function fixtureResponse(tokenPresent: boolean, overrides: { systemGhPresent?: boolean } = {}): JsonRecord { return { ok: true, status: 200, body: { ok: true, - runtimePreflight: fixtureRuntimePreflight(tokenPresent), + runtimePreflight: fixtureRuntimePreflight(tokenPresent, overrides), }, }; } @@ -96,6 +102,7 @@ export function runCodeQueuePrPreflightContract(): JsonRecord { { 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); @@ -110,10 +117,21 @@ export function runCodeQueuePrPreflightContract(): JsonRecord { }); 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 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 }); @@ -122,6 +140,19 @@ export function runCodeQueuePrPreflightContract(): JsonRecord { 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 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 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); return { ok: true, @@ -129,8 +160,12 @@ export function runCodeQueuePrPreflightContract(): JsonRecord { "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", ], }; } diff --git a/scripts/src/code-queue.ts b/scripts/src/code-queue.ts index 4a1bc2a9..1960dc90 100644 --- a/scripts/src/code-queue.ts +++ b/scripts/src/code-queue.ts @@ -2265,6 +2265,17 @@ function compactToolStatus(value: unknown): Record { }; } +function compactUniDeskGhCliStatus(value: unknown): Record { + const cli = asRecord(value) ?? {}; + return { + ok: cli.ok ?? false, + path: cli.path ?? null, + present: cli.present ?? false, + role: "repo-native REST GitHub CLI used by bun scripts/cli.ts gh", + requiresSystemGhBinary: false, + }; +} + function compactAgentPortStatus(value: unknown): Record { const port = asRecord(value) ?? {}; return { @@ -2298,6 +2309,7 @@ function tokenCoverageStatus(credentials: Record): Record, options: CodexPrPreflightOptions): Record { const pull = asRecord(preflight.pullRequestDelivery) ?? {}; const tools = asRecord(pull.tools) ?? {}; + const unideskGhCli = compactUniDeskGhCliStatus(pull.unideskGhCli); const credentials = asRecord(pull.credentials) ?? {}; const git = asRecord(pull.git) ?? {}; const githubContext = asRecord(pull.githubContext) ?? {}; @@ -2309,6 +2321,10 @@ function compactPrRuntimePreflight(preflight: Record, options: const limitations = Array.isArray(pull.limitations) ? pull.limitations.map(String) : []; const risks = Array.isArray(pull.risks) ? pull.risks.map(String) : []; const ok = preflight.ok === true && tokenCoverage.ok === true; + const defaultPushDryRunRef = "refs/heads/probe/code-queue-pr-capability-dryrun"; + const pushDryRunRef = options.pushDryRunRef ?? defaultPushDryRunRef; + const targetBranch = "master"; + const expectedHeadBranch = options.prCreateDryRunHead ?? (typeof git.branch === "string" && git.branch.length > 0 ? git.branch : ""); const result: Record = { ok, checkedAt: preflight.checkedAt ?? pull.checkedAt ?? null, @@ -2320,13 +2336,48 @@ function compactPrRuntimePreflight(preflight: Record, options: pid: preflight.pid ?? null, }, tokenCoverage, + prCapabilityContract: { + targetBranch, + tokenSource: tokenCoverage.source, + systemGhBinaryRequiredForWrites: false, + unideskGhCli, + pushDryRun: { + requested: options.pushDryRun, + ref: pushDryRunRef, + writesRemote: false, + commandShape: `git push --dry-run origin HEAD:${pushDryRunRef}`, + }, + prCreateDryRun: { + requested: options.prCreateDryRun, + headBranch: expectedHeadBranch, + writesRemote: false, + commandShape: `bun scripts/cli.ts gh pr create --repo pikasTech/unidesk --base ${targetBranch} --head ${expectedHeadBranch} --dry-run`, + }, + expectedPrHandoff: { + sourceBranch: expectedHeadBranch, + targetBranch, + 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.", + }, + }, tools: { git: compactToolStatus(tools.git), gh: compactToolStatus(tools.gh), + systemGhBinary: compactToolStatus(tools.gh), hub: compactToolStatus(tools.hub), jq: compactToolStatus(tools.jq), ssh: compactToolStatus(tools.ssh), curl: compactToolStatus(tools.curl), + unideskGhCli, }, agentPorts: { codex: compactAgentPortStatus(ports.codex), @@ -2404,6 +2455,7 @@ function codeQueuePrPreflight(optionArgs: string[] = [], fetcher: CodexResponseF const compact = compactPrRuntimePreflight(preflight, options); return { ok: compact.ok, + runnerDisposition: compact.runnerDisposition, upstream: response.upstream, preflight: compact, }; diff --git a/src/components/microservices/code-queue/src/runtime-preflight.ts b/src/components/microservices/code-queue/src/runtime-preflight.ts index 6fcfbeef..8d9cdc10 100644 --- a/src/components/microservices/code-queue/src/runtime-preflight.ts +++ b/src/components/microservices/code-queue/src/runtime-preflight.ts @@ -55,10 +55,17 @@ export interface RuntimePreflightReport { pullRequestDelivery: PullRequestDeliveryPreflight; } +export interface UniDeskGhCliStatus { + ok: boolean; + path: string; + present: boolean; +} + export interface PullRequestDeliveryPreflight { ok: boolean; checkedAt: string; tools: Record; + unideskGhCli: UniDeskGhCliStatus; credentials: { ghTokenPresent: boolean; githubTokenPresent: boolean; @@ -235,6 +242,13 @@ function fileExistsProbe(path: string): boolean { return shellProbe(`test -s ${shellQuote(path)}`, 2000).ok; } +function unideskGhCliStatus(): UniDeskGhCliStatus { + const root = gitValue(["rev-parse", "--show-toplevel"]) ?? process.cwd(); + const path = `${root.replace(/\/+$/u, "")}/scripts/cli.ts`; + const present = fileExistsProbe(path); + return { ok: present, path, present }; +} + function parseGitHubRepo(remote: string | null): string | null { const envRepo = process.env.GH_REPO?.trim(); if (envRepo !== undefined && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(envRepo)) return envRepo; @@ -370,6 +384,7 @@ function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, c ssh: toolStatus("ssh", ["-V"]), curl: toolStatus("curl", ["--version"]), }; + const unideskGhCli = unideskGhCliStatus(); const insideProbe = commandProbe("git", ["rev-parse", "--is-inside-work-tree"], { timeoutMs: 5000 }); const insideWorktree = insideProbe.ok && firstLine(insideProbe.stdout) === "true"; const home = process.env.HOME ?? null; @@ -452,7 +467,8 @@ function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, c const risks: string[] = []; if (!tools.git.ok) limitations.push("missing git CLI"); if (!tools.jq.ok) limitations.push("missing jq CLI"); - if (!tools.gh.ok && !tools.hub.ok) limitations.push("missing PR CLI: install gh or hub"); + if (!tools.gh.ok) risks.push("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"); + if (!unideskGhCli.ok) limitations.push("UniDesk gh CLI is missing at scripts/cli.ts; runner cannot use the repo-native REST PR create/comment path"); if (!credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("GH_TOKEN/GITHUB_TOKEN is not present; gh cannot create PRs unless another gh credential store is mounted"); if (!credentials.ghHostsConfigPresent && !credentials.gitCredentialsPresent && !credentials.ghTokenPresent && !credentials.githubTokenPresent) limitations.push("no gh hosts config, git credentials file, GH_TOKEN, or GITHUB_TOKEN is visible for GitHub API/issue/PR access"); if (!gitInfo.insideWorktree) limitations.push("current directory is not a Git worktree"); @@ -476,7 +492,7 @@ function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, c } if (pushDryRun !== undefined && !pushDryRun.ok) limitations.push("git push --dry-run failed for probe branch"); if (prCreateDryRun !== undefined && !prCreateDryRun.ok) limitations.push("PR create dry-run body/command guard failed"); - const prCliReady = tools.gh.ok || tools.hub.ok; + const prCliReady = unideskGhCli.ok; const tokenOrGhStore = credentials.ghTokenPresent || credentials.githubTokenPresent || credentials.ghHostsConfigPresent || (remote?.ghAuthStatus?.ok === true); const remoteReady = remote === undefined || (egress.githubDefault.ok && egress.apiDefault.ok && remote.gitLsRemote.ok && remote.githubSshAuthenticated); const pushReady = pushDryRun === undefined || pushDryRun.ok; @@ -485,6 +501,7 @@ function collectPullRequestDeliveryPreflight(options: RuntimePreflightOptions, c ok: tools.git.ok && tools.jq.ok && prCliReady && tokenOrGhStore && gitInfo.insideWorktree && gitInfo.remoteOrigin !== null && gitInfo.homeWritable && remoteReady && pushReady && prCreateDryRunReady, checkedAt, tools, + unideskGhCli, credentials, githubContext: { host: githubHost,