fix: classify transient GitHub connectivity

Merge PR #102 after rebasing onto current master and validating focused GitHub transient/Code Queue preflight behavior. GitHub DNS/API failures now classify as retryable github-transient with retry/backoff or keep-fresh-heartbeat-task guidance.
This commit is contained in:
Lyon
2026-05-23 15:49:29 +08:00
committed by GitHub
parent 10bb228df5
commit 55c1b296ff
7 changed files with 292 additions and 14 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
当前阻塞来自多个路径都把 GitHub 能力等同于 runner 环境变量:
- `scripts/src/gh.ts`UniDesk `gh` CLI 已经是 repo-native GitHub REST wrapper,优先读取 `GH_TOKEN`,其次读取 `GITHUB_TOKEN`,再 fallback 到系统 `gh auth token`。它不会打印 token 值,并已把 `missing-token``permission-denied``scope-insufficient``network-proxy-failed``unsupported-command` 等失败结构化。
- `scripts/src/gh.ts`UniDesk `gh` CLI 已经是 repo-native GitHub REST wrapper,优先读取 `GH_TOKEN`,其次读取 `GITHUB_TOKEN`,再 fallback 到系统 `gh auth token`。它不会打印 token 值,并已把 `missing-token``permission-denied``scope-insufficient``github-transient``network-proxy-failed``unsupported-command` 等失败结构化;其中 `github-transient` 是可重试的 GitHub DNS/API 连接失败,不等同于缺 token 或权限不足
- `scripts/code-queue-pr-preflight-example.ts`:本地示例先检查 env token,再跑 `gh auth status``gh pr create --dry-run``gh pr comment --dry-run`。没有 env token 时该 workflow 直接失败。
- `src/components/microservices/code-queue/src/runtime-preflight.ts`D601 scheduler preflight 检查 `GH_TOKEN` / `GITHUB_TOKEN` 是否存在,用 curl/gh/git 探测 GitHub API、issue、PR、SSH 和可选 push dry-run。
- `scripts/src/code-queue.ts``codex pr-preflight` 把 runtime preflight 压缩为 runner 合同;scheduler env token 与 active runner/dev container token 是独立 scope。缺少 scheduler env token 或 auth-broker 时输出 `failureKind=auth-missing``degradedReason=auth-broker-needed``runnerDisposition=infra-blocked`,并用 `authScopeSummary`/`scopeBoundary`/`recommendedActions` 明确这不等于当前 active runner 不能创建 PRactive task 内仍需用 repo-native `bun scripts/cli.ts gh auth status` 和 PR create dry-run 验证。
+2 -2
View File
@@ -31,7 +31,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
- `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine``rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。
- `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`
- `commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run` 是 host Codex 指挥官直管微服务 skeleton 入口。当前命令返回 `phase=source-contract`、service/API/state/bridge/prompt/trace/#20/#46/ClaudeQQ 审批边界、.state/commander/ 状态模型、dev 无 daemon smoke contract 和 dry-run 计划,服务骨架只提供本地 `/health``/api/commander/contract`、状态读写、trace summary 聚合和 approval draft preview,不接 live bridge、不注入 prompt、不发送 ClaudeQQ。`plan``smoke``approval request` 必须带 `--dry-run`;缺少时返回 `error=dry-run-required`。长期规则见 `docs/reference/host-codex-commander.md`
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary``missing-token``auth-failed``network-proxy-failed``permission-denied``repo-not-found``repo-forbidden``issue-not-found``pr-not-found``scope-insufficient``validation-failed``invalid-response``unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`runner 应优先用该字段分流。
- `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary``missing-token``auth-failed``github-transient``network-proxy-failed``permission-denied``repo-not-found``repo-forbidden``issue-not-found``pr-not-found``scope-insufficient``validation-failed``invalid-response``unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`runner 应优先用该字段分流。`github-transient` 表示 GitHub DNS/API 连接在收到 HTTP 状态前失败,输出应带 `retryable=true` 或等价 commander action;这不是缺 token、认证失败、权限不足或 PR 语义失败。
- `gh issue list [--state open|closed|all] [--limit N] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]` 通过 GitHub REST 列出 issue,默认 `state=open``limit=30`,输出稳定 JSON 且不依赖系统 `gh` binary。`--limit` 会映射到 GitHub `per_page` 并限制返回数量,避免一次拉爆上下文;未知 state 或未知 `--json` 字段必须结构化失败并带 `runnerDisposition=business-failed`。GitHub issues API 可能混入 PRCLI 会从 `.data.issues` 中过滤 pull request。
- `gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON`view` 只保留为兼容别名。`owner/repo#number` shorthand 会自动派生 `--repo owner/repo` 和 issue number;若同时提供冲突的显式 `--repo`CLI 必须结构化失败并给出 `gh issue read <number> --repo owner/repo --json body,title,state,comments` 与 shorthand raw 的可执行命令。兼容旧脚本的 `--json body``--json body,title,state,comments` 字段选择,且正文仍稳定暴露在 `.data.issue.body`,避免调用方因为 JSON 路径变化把空值当成正文。字段白名单是 `body,title,state,comments,number,url,author,createdAt,updatedAt`,未知字段必须结构化失败并带 `runnerDisposition=business-failed``--raw``--full` 只在 read/view 上可用,是显式完整披露别名,会选择完整支持字段集并保持结构化 JSON 输出;默认 list/read 输出仍不得扩散到无界非 JSON 文本。`gh issue create --title <title> --body-file <file> [--label label[,label...]]... [--dry-run]``gh issue update <number> --mode replace|append --body-file <file> [--title ...] [--dry-run]``gh issue comment create <number> --body-file <file> [--dry-run]``gh issue comment delete <commentId> [--dry-run]``gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary。`--label` 仅用于 `issue create`,支持重复传入和逗号分隔;`--dry-run` 会展示解析后的 labels 与 request plan,正式创建时把 labels 放入 GitHub REST create-issue payloadGitHub 返回不存在 label 等 422 校验失败时 CLI 结构化返回 `validation-failed`,不静默成功。`gh issue delete <number>` 是结构化 `unsupported-command`,因为 GitHub REST 不支持 issue 硬删除;生命周期删除语义请使用 `close`
- `gh issue update <number> --mode replace|append --body-file <file>` 是正文更新主入口,`edit` 保留为兼容别名。`replace` 用文件正文替换现有 body`append` 先读取当前 body,再按 UTF-8 文件字节追加,保留真实换行、反引号和 Markdown 表格。更新默认拒绝字面量 `null`、空白正文和过短正文;只有真实需要写短正文时才允许显式加 `--allow-short-body`,返回 JSON 会报告该风险。#20 总看板和指挥简报类 issue 是长期 body-only issue`--body-profile auto` 会按 issue number 自动启用 #20/#24 legacy guard#20 必须包含 `## 看板(OPEN`#24 legacy 指挥简报必须包含 `## 常驻观察与长期建议`。显式 `--body-profile commander-brief` 不再固定 #24#24 仍兼容,标题为 `YYYY-MM-DD 指挥简报(北京时间)` 或既有正文首行/关键 heading 表明为每日滚动指挥简报的 issue 也合法,并仍必须包含 `## 常驻观察与长期建议`。对非简报 issue 显式使用 `commander-brief` 会结构化失败为 `profile-issue-mismatch``--dry-run` 不 PATCH GitHub,输出新正文长度、SHA、关键标题检查结果、字面量 `\n`、反引号、Markdown 表格和 shell 污染信号;若环境里有 `GH_TOKEN``GITHUB_TOKEN`,dry-run 还会只读抓取旧正文长度、SHA 和 `updatedAt` 作为更新前对照。正式写入可带 `--expect-updated-at <updated_at>``--expect-body-sha <sha256>`,CLI 会先读当前 issue,匹配后才 PATCH,防止旧缓存覆盖新正文。
@@ -45,7 +45,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 <scheduleId> --limit N` 是只读观察入口;`schedule run``schedule retry-run``schedule delete``schedule upsert-pgdata-backup` 会触发运行或写入配置,生产恢复时必须有明确授权。`schedule runs --limit N` 是全局历史视图,返回 `scope=global``scheduleId=null``schedule runs <scheduleId> --limit N` 是指定 schedule 历史视图,返回 `scope=schedule` 和对应 `scheduleId`。CLI 必须拒绝 `schedule runs 50` 这类纯数字位置参数,并提示使用 `schedule runs --limit 50`,避免把空数组误判成“没有历史 run”。`schedule run <id> --wait-ms N` 触发同一 schedule,并且即使 wait 超时也必须返回 `newRunId``observeCommand``schedule retry-run <failedRunId>` 只接受 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 <commitId>` 是旧 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` 只返回结构化请求且不实际入队。长 prompt、多行 prompt、含引号/反引号/Markdown 表格/JSON/反斜杠的 prompt 必须优先用 `--prompt-stdin``--prompt-file`,不要拼进 shell 单个参数;位置参数只适合短单行 smoke prompt。stdin 推荐用 quoted heredoc`cat <<'PROMPT' | bun scripts/cli.ts codex submit --prompt-stdin --queue <id> --dry-run`,文件路径推荐 `bun scripts/cli.ts codex submit --prompt-file /tmp/code-queue-prompt.md --queue <id> --dry-run`,确认 dry-run 后移除 `--dry-run` 提交同一 payload。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` 用于人工验收;真实提交是写入操作,默认只返回 `accepted=true`、task id、队列、写入保护摘要和后续查看命令,必须标记 `promptOmitted=true` 且不得回显 prompt 或 promptPreview。真实提交会经过本机本地串行化保护和短节流,避免同一指挥端并发 submit 把低内存主机或 `code-queue-mgr` 控制面打抖;返回值会附带低噪声 `submitConcurrencyGuard` 说明本次提交的锁与等待信息。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQLD601 scheduler 只轮询并执行已入库任务。
- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、Auth Broker source/capability/nextAction、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"``credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"``preflight.authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false``runnerDisposition=infra-blocked``degradedReason=auth-broker-needed``tokenCoverage.missing` 同时列出 `GH_TOKEN``GITHUB_TOKEN`,并输出 `preflight.authBroker.source="broker/auth-broker-needed"``capability.source="missing-token"`。该 `auth-missing` 的 scope 是 `scheduler-runner-env`,不能简化成“当前 active runner/dev container 不能创建 PR”;输出必须带 `scopeBoundary``activeRunnerDevContainer`,要求调用方另跑 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 与 PR dry-run 来确认当前 dev container 能力。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token/auth 来源、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff、真实 PR 创建需要 commander 授权和 `gh pr merge``unsupported-command` 边界;系统 `gh` binary 缺失只进入 `tools.systemGhBinary`,不得误判为 UniDesk REST `gh` CLI 不可用。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core``unidesk-database``baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=control-plane-missing` / `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 broker、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。
- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。输出会压缩展示 scheduler/runner 的 token 覆盖、Auth Broker source/capability/nextAction、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测、可选 push dry-run,以及可选 PR body/create dry-run guard;只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"``credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"``preflight.authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false``runnerDisposition=infra-blocked``degradedReason=auth-broker-needed``tokenCoverage.missing` 同时列出 `GH_TOKEN``GITHUB_TOKEN`,并输出 `preflight.authBroker.source="broker/auth-broker-needed"``capability.source="missing-token"`。该 `auth-missing` 的 scope 是 `scheduler-runner-env`,不能简化成“当前 active runner/dev container 不能创建 PR”;输出必须带 `scopeBoundary``activeRunnerDevContainer`,要求调用方另跑 `bun scripts/cli.ts gh auth status --repo pikasTech/unidesk` 与 PR dry-run 来确认当前 dev container 能力。GitHub DNS/API 连接失败应归类为 `failureKind=github-transient``degradedReason=github-dns-api-transient`,并带 `retryable=true``commanderAction=retry-backoff-or-keep-running-if-heartbeat-fresh` 和有界 `githubTransient.failedProbes`;调用方应重试/退避,且在任务 heartbeat/trace 新鲜时继续监督,不把它当成 auth 缺失或 PR 语义失败。`preflight.prCapabilityContract` 是 runner-facing 合同摘要,必须包含目标分支、token/auth 来源、`systemGhBinaryRequiredForWrites=false`、UniDesk REST `bun scripts/cli.ts gh` 可用性、push dry-run/PR create dry-run 的 `writesRemote=false`、expected PR handoff、真实 PR 创建需要 commander 授权和 `gh pr merge``unsupported-command` 边界;系统 `gh` binary 缺失只进入 `tools.systemGhBinary`,不得误判为 UniDesk REST `gh` CLI 不可用。`--remote` 在 runner-like 环境里不再依赖本地 `unidesk-backend-core``unidesk-database``baidu-netdisk-backend` 容器存在;这些缺失只作为本地观测证据。若远程控制面可达,则继续走远程控制面结果;若远程控制面不可达,则结构化返回 `failureKind=control-plane-missing` / `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 broker、repo/issue/PR read、push dry-run 和最终授权后的真实 PR 创建结果为准。
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化审阅摘要;默认只返回任务身份、执行 Provider、工作目录、attempt 计数、原始 prompt、最终 response、最后错误和渐进披露命令,适合指挥官审阅完成未读任务且避免上下文爆炸。`--detail` 仍是有界详细摘要:默认只返回少量 attempt/tool 行、短 prompt/response/stderr/feedback 预览和 omitted/truncated 元数据;需要完整 prompt/response 文本或更多 tool/attempt 细节时再显式加 `--full``--tool-limit N``--trace``codex output`。该摘要读取默认由主 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` 是低噪声指挥官视图,只返回 `activeRunning``running``completedUnread``recentCompleted``queued``activity``commanderConcurrency``executionDiagnostics` 的紧凑行;`activeRunning.count` 是 running+judging 的状态计数,`exact=true` 时来自 queue summary counts`running.returned``activeRunning.rowPage.returned` 只是本次返回的紧凑行数。`commanderConcurrency.activeRunnerCount` 是并发策略应使用的 active/running 计数,等于 `activity.effectiveActiveTaskCount`15 并发策略按 `15 - activeRunnerCount` 计算剩余窗口。`commanderConcurrency.splitBrainDisposition=live-count-as-active` 表示 split-brain 有 fresh heartbeat 证据,应继续监督并计入 active;`interventionRequired=true` 才提示介入。prompt/body 只给短预览和原始字符数,`running`/`completedUnread`/`queued` 默认只返回一个有界小页并通过 section `commands.next` 继续分页,`recentCompleted` 默认限量且不重复 `completedUnread` 未读终态,不嵌入完整 Trace、final response 或全量 overview。`--limit` 在 supervisor 中主要是扫描/分页预算,不是返回几十条肥行的开关;CLI 安全上限是 100,输出会在 `filters.requestedLimit``filters.effectiveLimit``filters.limitCapped``disclosure.limitPolicy` 说明显式请求是否被 capped;底层 overview 拉取预算独立显示在 `source.requestedLimit` / `source.effectiveLimit`,所以 `--limit 260` 应显示 requested=260、effective=100、source requested/effective=200,而不是只露出一个含糊的 `limit``--unread``--unread-only` 的别名,必须只保留未读终态;`--status` 必须真实过滤支持的状态,未知参数或未知状态必须结构化失败。需要更详细当前页任务行时显式使用 `--view full``--full`,仍受 `--limit``--before-id` 分页约束。
- `codex task <taskId> --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`
File diff suppressed because one or more lines are too long
@@ -546,6 +546,69 @@ async function main(): Promise<void> {
assertCondition(proxyGapRecord.failureKind === "proxy-gap", "proxy failures should classify as proxy-gap", proxyGapRecord);
assertCondition(proxyGapRecord.degradedReason === "configured GitHub egress proxy host is not resolvable: missing-egress-proxy.unidesk.svc.cluster.local", "proxy degraded reason should point at the proxy", proxyGapRecord);
const githubTransientContract = await codexPrPreflightQueryForTest(["--remote"], {
config: null,
coreFetch: () => ({
ok: true,
status: 200,
body: {
runtimePreflight: rawRuntimePreflightFixture({
ok: false,
pullRequestDelivery: {
...asRecord(rawRuntimePreflightFixture().pullRequestDelivery),
ok: false,
credentials: {
ghTokenPresent: true,
githubTokenPresent: false,
ghHostsConfigPresent: false,
gitCredentialsPresent: false,
},
egress: {
proxy: {
selectedProxyHost: "d601-provider-egress-proxy.unidesk.svc.cluster.local",
selectedProxyPort: "18789",
selectedProxyHostResolvable: true,
},
githubDefault: { command: "curl", args: ["-IsS", "https://github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: github.com" },
apiDefault: { command: "curl", args: ["-IsS", "https://api.github.com"], ok: false, exitCode: 6, signal: null, error: null, stdout: "", stderr: "curl: (6) Could not resolve host: api.github.com" },
issueApi: null,
},
remote: {
gitLsRemote: { command: "git", args: ["ls-remote", "--heads", "origin", "master"], ok: false, exitCode: 128, signal: null, error: null, stdout: "", stderr: "ssh: Could not resolve hostname github.com: Temporary failure in name resolution" },
gitHttpsLsRemote: null,
githubSshAuthenticated: false,
ghAuthStatus: { command: "gh", args: ["auth", "status"], ok: false, exitCode: 1, signal: null, error: null, stdout: "", stderr: "error connecting to api.github.com" },
ghRepoView: null,
ghIssueView: null,
ghPrReadOnly: null,
},
limitations: [
"GitHub HTTPS probe failed with the default environment/proxy",
"GitHub API probe failed with the default environment/proxy",
"git ls-remote origin master failed",
],
risks: [],
},
}),
},
}),
});
const githubTransientRecord = asRecord(githubTransientContract);
assertCondition(githubTransientRecord.failureKind === "github-transient", "GitHub DNS/API failures should classify separately from auth and semantic failures", githubTransientRecord);
assertCondition(githubTransientRecord.degradedReason === "github-dns-api-transient", "GitHub transient degraded reason should be stable", githubTransientRecord);
assertCondition(githubTransientRecord.runnerDisposition === "infra-blocked", "GitHub transient keeps infra-blocked disposition for legacy callers", githubTransientRecord);
assertCondition(githubTransientRecord.retryable === true, "GitHub transient should expose top-level retryable=true", githubTransientRecord);
assertCondition(githubTransientRecord.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose top-level commander action", githubTransientRecord);
const githubTransientPreflight = asRecord(githubTransientRecord.preflight);
assertCondition(githubTransientPreflight.retryable === true, "GitHub transient preflight should be retryable", githubTransientPreflight);
assertCondition(githubTransientPreflight.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose bounded commander action", githubTransientPreflight);
const githubTransient = asRecord(githubTransientPreflight.githubTransient);
assertCondition(githubTransient.kind === "github-transient", "GitHub transient evidence should identify kind", githubTransient);
assertCondition(githubTransient.notAuthMissing === true, "GitHub transient must not be auth-missing", githubTransient);
assertCondition(githubTransient.notPrSemanticFailure === true, "GitHub transient must not be PR semantic failure", githubTransient);
assertCondition(Array.isArray(githubTransient.failedProbes) && githubTransient.failedProbes.length <= 4, "GitHub transient evidence should stay bounded", githubTransient);
assertCondition(String(githubTransient.commanderAction ?? "").includes("keep the task running"), "GitHub transient action should preserve fresh-heartbeat tasks", githubTransient);
let observedDryRunPath = "";
const dryRunContract = await codexPrPreflightQueryForTest([
"--remote",
@@ -820,6 +883,7 @@ async function main(): Promise<void> {
"local backend-core absence returns runner-local-observation-gap",
"auth missing returns auth-missing with broker/auth-broker-needed",
"proxy failures return proxy-gap",
"GitHub DNS/API failures return github-transient with retry/backoff guidance",
"git remote failures return git-remote-gap",
"combined push/PR create dry-run contract stays read-only and separates system gh from UniDesk gh CLI",
"broker-issued token, env-token, and missing-token branches expose authBroker source/capability/nextAction",
+34
View File
@@ -202,6 +202,21 @@ async function startMockGitHub(): Promise<{ baseUrl: string; requests: MockReque
};
}
async function startResetGitHub(): Promise<{ baseUrl: string; close: () => Promise<void> }> {
const server = createServer((req) => {
req.socket.destroy();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assertCondition(typeof address === "object" && address !== null, "reset mock server should expose address");
const port = (address as AddressInfo).port;
assertCondition(typeof port === "number", "reset mock server should expose port");
return {
baseUrl: `http://127.0.0.1:${port}`,
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
};
}
function dataOf(response: JsonRecord): JsonRecord {
assertCondition(response.ok === true, "CLI command should succeed", response);
assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "response data should be object", response);
@@ -383,6 +398,24 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
await mock.close();
}
const resetMock = await startResetGitHub();
try {
const transient = await runCli(["gh", "auth", "status", "--repo", "pikasTech/unidesk"], {
GH_TOKEN: "contract-token",
UNIDESK_GITHUB_API_URL: resetMock.baseUrl,
});
assertCondition(transient.status !== 0, "GitHub DNS/API transient should fail structurally", transient.json ?? { stdout: transient.stdout });
const transientData = failedDataOf(transient.json ?? {});
assertCondition(transientData.degradedReason === "github-transient", "GitHub DNS/API transient should not be auth-missing or semantic failure", transientData);
assertCondition(transientData.runnerDisposition === "infra-blocked", "GitHub transient should remain infra-blocked", transientData);
const transientDetails = transientData.details as JsonRecord;
assertCondition(transientDetails.retryable === true, "GitHub transient should be retryable", transientDetails);
assertCondition(transientDetails.commanderAction === "retry-backoff-or-keep-running-if-heartbeat-fresh", "GitHub transient should expose bounded commander action", transientDetails);
assertCondition(!transient.stdout.includes("contract-token"), "GitHub transient output must not print token values", { stdout: transient.stdout });
} finally {
await resetMock.close();
}
const title = "contract pr create";
const bodyFile = join(tmpdir(), `unidesk-gh-pr-contract-${process.pid}.md`);
writeFileSync(bodyFile, "Line 1\n`code`\n| a | b |\n", "utf8");
@@ -519,6 +552,7 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
"pr read/view accept owner/repo#number shorthand and reject conflicting --repo",
"pr read/view --raw is explicit full disclosure",
"pr read normalizes open and merged lifecycle fields from REST",
"GitHub DNS/API transients are retryable and distinct from auth or PR semantic failures",
"pr view closeout metadata fields are accepted and hydrated through GraphQL",
"pr read unsupported fields fail structurally with supported closeout fields listed",
"pr create dry-run exposes planned operation",
+81 -7
View File
@@ -320,7 +320,7 @@ interface CodexSkillsSyncOptions {
full: boolean;
}
type CodeQueuePrPreflightFailureKind = "auth-missing" | "runner-skills-blocker" | "proxy-gap" | "git-remote-gap" | "control-plane-missing" | "target-stack-not-running";
type CodeQueuePrPreflightFailureKind = "auth-missing" | "runner-skills-blocker" | "github-transient" | "proxy-gap" | "git-remote-gap" | "control-plane-missing" | "target-stack-not-running";
type CodeQueueObservationGapKind = "runner-local-observation-gap" | "control-plane-observation-gap" | null;
interface CodeQueuePrPreflightTransport {
@@ -3817,6 +3817,64 @@ function compactCommandProbe(value: unknown): Record<string, unknown> | null {
};
}
function commandProbeFailureText(value: unknown): string {
const probe = asRecord(value);
if (probe === null || probe.ok === true) return "";
const args = Array.isArray(probe.args) ? probe.args.map(String).join(" ") : "";
return [
typeof probe.command === "string" ? probe.command : "",
args,
typeof probe.error === "string" ? probe.error : "",
typeof probe.stderr === "string" ? probe.stderr : "",
typeof probe.stdout === "string" ? probe.stdout : "",
].filter((item) => item.length > 0).join("\n");
}
function commandProbeLooksLikeGithubTransient(value: unknown): boolean {
const text = commandProbeFailureText(value);
if (text.length === 0) return false;
if (/\bproxy\b/iu.test(text) && /could not resolve|connect|tunnel|proxy/iu.test(text)) return false;
return /temporary failure in name resolution|error connecting to api\.github\.com|getaddrinfo|could not resolve host:? (github\.com|api\.github\.com)|name or service not known|eai_again|enotfound|etimedout|econnreset|ehostunreach|enetunreach|failed to connect to (github\.com|api\.github\.com)|network is unreachable/iu.test(text);
}
function githubTransientEvidence(pull: Record<string, unknown>): Record<string, unknown> | null {
const egress = asRecord(pull.egress) ?? {};
const remote = asRecord(pull.remote);
const probes: Array<[string, unknown]> = [
["egress.githubDefault", egress.githubDefault],
["egress.apiDefault", egress.apiDefault],
["egress.issueApi", egress.issueApi],
];
if (remote !== null) {
probes.push(
["remote.gitLsRemote", remote.gitLsRemote],
["remote.gitHttpsLsRemote", remote.gitHttpsLsRemote],
["remote.ghAuthStatus", remote.ghAuthStatus],
["remote.ghRepoView", remote.ghRepoView],
["remote.ghIssueView", remote.ghIssueView],
["remote.ghPrReadOnly", remote.ghPrReadOnly],
);
}
const failedProbes = probes
.map(([name, probe]) => ({ name, probe, text: commandProbeFailureText(probe) }))
.filter((item) => commandProbeLooksLikeGithubTransient(item.probe))
.slice(0, 4);
if (failedProbes.length === 0) return null;
return {
kind: "github-transient",
retryable: true,
scope: "github-dns-api",
failedProbes: failedProbes.map((item) => ({
name: item.name,
preview: compactInlinePreview(item.text, 240),
})),
commanderAction: "retry/backoff; if Code Queue heartbeat or trace is fresh, keep the task running and continue supervision",
notAuthMissing: true,
notPrSemanticFailure: true,
valuesPrinted: false,
};
}
function compactToolStatus(value: unknown): Record<string, unknown> {
const tool = asRecord(value) ?? {};
return {
@@ -4183,21 +4241,26 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, 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 githubTransient = githubTransientEvidence(pull);
const skillsBlocked = skills !== null && skills.ok !== true;
const skillsSyncBlocked = skillsSync !== null && skillsSync.ok !== true;
const failureKind = !tokenCoverage.ok
? "auth-missing"
: skillsBlocked || skillsSyncBlocked
? "runner-skills-blocker"
: limitations.some((item) => item.includes("git ls-remote") || item.includes("git push --dry-run failed"))
? "git-remote-gap"
: !preflight.ok
? "proxy-gap"
: null;
: githubTransient !== null
? "github-transient"
: limitations.some((item) => item.includes("git ls-remote") || item.includes("git push --dry-run failed"))
? "git-remote-gap"
: !preflight.ok
? "proxy-gap"
: null;
const degradedReason = failureKind === "auth-missing"
? "auth-broker-needed"
: failureKind === "runner-skills-blocker"
? typeof skillsSync?.blocker === "string" ? skillsSync.blocker : typeof skills?.blocker === "string" ? skills.blocker : "runner-skills-degraded"
: failureKind === "github-transient"
? "github-dns-api-transient"
: failureKind === "git-remote-gap"
? "git remote probe failed"
: failureKind === "proxy-gap"
@@ -4326,12 +4389,19 @@ function compactPrRuntimePreflight(preflight: Record<string, unknown>, options:
limitations,
risks,
runnerDisposition: ok ? "ready" : "infra-blocked",
...(githubTransient === null ? {} : {
retryable: true,
commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
githubTransient,
}),
activeRunnerDevContainer,
recoveryHint: ok
? tokenCoverage.source === "auth-broker"
? "Runner PR workflow has auth-broker coverage for GitHub REST preflight; real PR creation still requires commander authorization."
: "Runner PR workflow has env-token coverage for the scheduler."
: "Scheduler preflight lacks GitHub auth coverage for scheduler-scoped admission only. First verify the active task with repo-native gh auth status and PR create dry-run; long-term configure auth-broker or inject GH_TOKEN/GITHUB_TOKEN into the scheduler runtime secret.",
: githubTransient !== null
? "GitHub DNS/API connectivity failed transiently. Retry with backoff; if the task heartbeat or trace is fresh, keep supervising the running task instead of closing or requeueing business work."
: "Scheduler preflight lacks GitHub auth coverage for scheduler-scoped admission only. First verify the active task with repo-native gh auth status and PR create dry-run; long-term configure auth-broker or inject GH_TOKEN/GITHUB_TOKEN into the scheduler runtime secret.",
commands: {
local: "bun scripts/cli.ts gh auth status --repo pikasTech/unidesk",
runner: "bun scripts/cli.ts codex pr-preflight --remote",
@@ -4582,6 +4652,8 @@ function codeQueuePrPreflight(optionArgs: string[] = [], transport: CodeQueuePrP
runnerDisposition: compact.runnerDisposition,
failureKind: compact.failureKind ?? null,
degradedReason: compact.degradedReason ?? null,
...(compact.retryable === true ? { retryable: true } : {}),
...(typeof compact.commanderAction === "string" ? { commanderAction: compact.commanderAction } : {}),
authScopeSummary: compact.authScopeSummary,
scopeBoundary: compact.scopeBoundary,
activeRunnerDevContainer: compact.activeRunnerDevContainer,
@@ -4638,6 +4710,8 @@ export async function codexPrPreflightQueryAsync(optionArgs: string[], fetcher:
runnerDisposition: compact.runnerDisposition,
failureKind: compact.failureKind ?? null,
degradedReason: compact.degradedReason ?? null,
...(compact.retryable === true ? { retryable: true } : {}),
...(typeof compact.commanderAction === "string" ? { commanderAction: compact.commanderAction } : {}),
authScopeSummary: compact.authScopeSummary,
scopeBoundary: compact.scopeBoundary,
activeRunnerDevContainer: compact.activeRunnerDevContainer,
+108 -2
View File
@@ -77,6 +77,7 @@ type GitHubDegradedReason =
| "missing-token"
| "auth-failed"
| "egress-failed"
| "github-transient"
| "network-proxy-failed"
| "permission-denied"
| "repo-not-found"
@@ -359,6 +360,8 @@ interface GitHubErrorPayload {
runnerDisposition: RunnerDisposition;
status?: number;
message: string;
retryable?: boolean;
commanderAction?: string;
details?: unknown;
scopes?: {
accepted: string | null;
@@ -1347,6 +1350,97 @@ function sanitizedErrorDetails(parsed: unknown): unknown {
return details;
}
function errorProperty(value: unknown, key: string): string | null {
if (!isRecord(value)) return null;
const item = value[key];
return typeof item === "string" && item.length > 0 ? item : null;
}
function errorChainText(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
if (current instanceof Error) {
parts.push(current.name, current.message);
current = (current as Error & { cause?: unknown }).cause;
continue;
}
if (isRecord(current)) {
for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) {
const value = errorProperty(current, key);
if (value !== null) parts.push(value);
}
current = current.cause;
continue;
}
parts.push(String(current));
break;
}
return parts.join(" ").replace(/\s+/gu, " ").trim();
}
function firstErrorProperty(error: unknown, key: string): string | null {
let current: unknown = error;
const seen = new Set<unknown>();
while (current !== null && current !== undefined && !seen.has(current)) {
seen.add(current);
const value = errorProperty(current, key);
if (value !== null) return value;
current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null;
}
return null;
}
function classifyGitHubFetchFailure(error: unknown): {
reason: GitHubDegradedReason;
message: string;
retryable?: boolean;
commanderAction?: string;
details: Record<string, unknown>;
} {
const text = errorChainText(error);
const lower = text.toLowerCase();
const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text);
const isGitHubTransient = !isProxyFailure && (
/temporary failure in name resolution/iu.test(text)
|| /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text)
|| /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text)
);
if (!isGitHubTransient) {
return {
reason: "network-proxy-failed",
message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response",
details: {
scope: "github-api",
transient: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
},
};
}
return {
reason: "github-transient",
message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response",
retryable: true,
commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
details: {
scope: "github-api",
transient: true,
retryable: true,
authFailure: false,
semanticFailure: false,
errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
errorCode: firstErrorProperty(error, "code"),
host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work",
valuesPrinted: false,
},
};
}
function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
return {
ok: false,
@@ -1431,7 +1525,13 @@ async function githubRequest<T>(
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method, path } });
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method, path },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} finally {
clearTimeout(timeout);
}
@@ -1473,7 +1573,13 @@ async function githubGraphqlRequest<T>(
body: JSON.stringify({ query, variables }),
});
} catch (error) {
return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method: "POST", path: "/graphql" } });
const classified = classifyGitHubFetchFailure(error);
return errorPayload(classified.reason, classified.message, {
request: { method: "POST", path: "/graphql" },
details: classified.details,
retryable: classified.retryable,
commanderAction: classified.commanderAction,
});
} finally {
clearTimeout(timeout);
}