feat: add bounded codex unread triage
This commit is contained in:
@@ -45,12 +45,13 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。
|
||||
- `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contract,runner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。
|
||||
- `bun scripts/cli.ts gh auth status|issue ...|pr list|files|diff --stat|read|view|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。
|
||||
- `bun scripts/cli.ts gh auth status|issue ...|pr list|files|diff --stat|read|view|create|edit|update|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR changed-file/stat summary、PR 创建/评论 dry-run、REST-only 低噪声 PR title/body 编辑、PR 收口元数据观察(含 merged/closed 区分与 merge commit)与 runner PR preflight;`gh issue/pr read|view` 支持 `owner/repo#number` shorthand,`--raw|--full` 是显式完整披露别名,`gh pr diff` 仅支持 `--stat` 紧凑 JSON,`gh pr merge` 当前仍结构化拒绝但普通 PR 可按任务边界用 repo-owned GitHub 路径收口,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。
|
||||
- `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`。
|
||||
- `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2e;catalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md`,`run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`,Tekton 规则见 `docs/reference/ci.md`。
|
||||
- `bun scripts/cli.ts codex deploy <commitId>`:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。
|
||||
- `bun scripts/cli.ts codex prompt-lint [prompt|--prompt-file path|--prompt-stdin]` / `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue <id>]` / `codex pr-preflight [--remote]`:`prompt-lint` 在派发/steer 前 dry-run 检查 runner prompt 的 DEV 测试授权分级(`read-only`/`live-read`/`live-mutating`)且不回显 prompt;`submit --dry-run` 同时给出 MiniMax/GPT/人工路由建议和该 lint 结果但不改写 payload,真实提交成功只返回写入确认、task id 和后续查看命令,不回显 prompt;`pr-preflight` 只读检查 D601 scheduler/runner 的 GitHub token、egress 和 PR 能力,PR 型派单前必须使用,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。
|
||||
- `bun scripts/cli.ts codex task <taskId>`:按 Code Queue 任务 ID 查询默认审阅摘要,只返回原始 prompt、最终 response、最后错误和渐进披露命令;`--detail`、`codex output` 和 supervisor 大 `--limit` 仍默认有界,完整内容需显式 `--full`/`--full-text`/分页展开;`codex queues [--full] [--limit N] [--page N|--offset N]` 默认分页低噪声输出队列摘要,完整 upstream 只通过 raw command 显式获取。
|
||||
- `bun scripts/cli.ts codex unread [--repo owner/name] [--issue N] [--limit N]`:只读汇总完成未读积压并给出 repo/issue/status/queue 计数和 drill-down/read 命令;批量已读必须显式 `codex unread mark-read ... --confirm`,规则见 `docs/reference/cli.md`。
|
||||
- `bun scripts/cli.ts codex judge <taskId> --attempt <n> [--dry-run]`:按指定 task/attempt 用与队列 worker 相同的上下文构建和 MiniMax judge 调用路径单步复现完成判定;`--dry-run` 只输出 prompt/payload 诊断。
|
||||
- `bun scripts/cli.ts codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run] [--no-retry|--retry-attempts N]`:通过 Code Queue 私有代理向运行中的 active turn 注入纠偏提示,对 retryable tunnel abort 做有界重试诊断,真实成功只确认写入并返回后续查看命令,不回显 prompt 或完整 task state。
|
||||
- `bun scripts/cli.ts codex interrupt|cancel <taskId>`:通过 Code Queue 私有代理中断运行任务或取消 queued/retry_wait 任务,规则见 `docs/reference/cli.md`。
|
||||
|
||||
@@ -49,6 +49,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
|
||||
- `codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full|--raw]` 通过稳定 `code-queue` proxy 请求 D601 scheduler `/api/runtime-preflight`,用于 PR 型派单 admission。默认输出是紧凑 commander 视图,显式分出 `schedulerPreflight` 与 `activeRunnerPrCapability`,并附带 `commands` 和 `disclosure`,方便先看 scheduler auth 缺口、再看当前 runner/dev container 的 `gh auth status` 与 `gh pr create --dry-run` 能力;`--full` 或 `--raw` 才展开完整 `preflight`、工具、agent port、Git worktree、GitHub egress、repo/issue/PR 只读探测和观测原文。只报告 `GH_TOKEN`/`GITHUB_TOKEN` 是否存在和来源 key,不打印值。当 auth-broker 配置存在时,`tokenCoverage.source="auth-broker"`、`credentialSource="broker-issued-token"` 且 runner env token 不是成功前提;当仅 env token 存在时,`credentialSource="env-token"` 且 `authBroker.nextAction="use-env-token-until-auth-broker-live"`;两者都缺失时顶层 `ok=false`、`runnerDisposition=infra-blocked`、`degradedReason=auth-broker-needed`,`tokenCoverage.missing` 同时列出 `GH_TOKEN` 与 `GITHUB_TOKEN`,并输出 `authBroker.source="broker/auth-broker-needed"`、`capability.source="missing-token"`。该 `auth-missing` 的 scope 是 `scheduler-runner-env`,不能简化成“当前 active runner/dev container 不能创建 PR”;默认视图必须带 `scopeBoundary` 和 `activeRunnerPrCapability`。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 语义失败。`prCapability` 是 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 unread [summary|mark-read] [--queue id] [--repo owner/name] [--issue N] [--status succeeded|failed|canceled[,..]] [--limit N] [--before-id id] [--confirm]` 是完成未读积压的默认低噪声 triage 入口。默认只读返回 repo/issue/status/queue 计数和最新任务 id 小页,不拉取 per-task summary,不输出 raw prompt、final response、trace 或 output;每行只给 `codex task/detail/trace/output/read` drill-down 命令。批量已读必须使用 `codex unread mark-read ... --confirm`,缺少 `--confirm` 时结构化失败且不 POST `/read`;单任务审阅仍优先 `codex read <taskId>`。
|
||||
- `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`。
|
||||
- `codex output <taskId> --tail|--from-start|--after-seq N|--before-seq N --limit N [--full-text]` 按原始 output seq 分页读取底层记录;当 trace 行提示 `commandOmittedLines`、`bodyOmittedLines` 或 `rawSeqs` 时,用该命令按 seq 补取信息。默认是低噪声 raw-output 摘要:即使传入很大的 `--limit`,非 `--full-text` 也会限制返回行数和单条文本预览,并在 `disclosure.limitCapped`、`requestedLimit`、`effectiveLimit` 和 `commands.fullText` 中说明如何继续展开;显式 `--full-text` 才返回该页全文。
|
||||
- `codex read <taskId>` 在人工审阅后标记单个终态任务已读,并在同一次响应中返回稳定任务身份、执行元数据、终态 attempt 摘要、最后错误或 judge 信息和最终 response,避免标记已读后还要额外 drill-down 才能确认结果。该命令不返回完整 prompt、tool logs 或 feedback prompt,只返回字符数、计数和 `codex task/detail/trace/output` 渐进披露命令;列表、overview 和 supervisor 视图只返回这个命令字段,不得自动执行,也不得批量清空未读状态。
|
||||
@@ -151,7 +152,7 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*-
|
||||
|
||||
`--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
|
||||
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`artifact-registry status|health`、`ci publish-user-service --dry-run`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/history/months/show/edit/upsert`、`codex task <taskId>`、`codex tasks`、`codex queues`、`codex output <taskId>`、`codex judge <taskId> --attempt N` 和 `ssh <PROVIDER_ID> <remote-command>`。`microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary,`microservice health code-queue` 只有显式 `--raw` 或 `--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 和 `ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。当 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失时,这些 read-only 预检必须返回结构化 `runnerDisposition=infra-blocked` 和缺失通道列表,而不是裸 `No such container`。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`artifact-registry status|health`、`ci publish-user-service --dry-run`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/history/months/show/edit/upsert`、`codex task <taskId>`、`codex tasks`、`codex unread`、`codex queues`、`codex output <taskId>`、`codex judge <taskId> --attempt N` 和 `ssh <PROVIDER_ID> <remote-command>`。`microservice status/health/diagnostics` 经 frontend 远程传输时也复用本地 CLI 的默认 compact summary,`microservice health code-queue` 只有显式 `--raw` 或 `--full` 才返回完整健康 body。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 和 `ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。当 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失时,这些 read-only 预检必须返回结构化 `runnerDisposition=infra-blocked` 和缺失通道列表,而不是裸 `No such container`。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
|
||||
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
|
||||
|
||||
|
||||
@@ -179,9 +179,9 @@ CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不
|
||||
|
||||
PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。
|
||||
|
||||
PR handoff 的职责固定分开:runner 实现、测试、提交、push head branch 并创建 PR;指挥官监督并发、steer、审阅、确认 checks 和合并裁决。runner 不合并自己的 PR,host commander 也不把直接编辑业务代码当成常规 PR 替代路径。
|
||||
PR handoff 的职责默认分开:runner 实现、测试、提交、push head branch 并创建 PR;指挥官监督并发、steer、审阅、确认 checks 和合并裁决。短期内 GPT-5.5 runner 如果收到明确 PR 收口授权,并且 PR 是普通 UniDesk source 变更、checks 满足任务要求、无冲突且不涉及 prod/runtime/release/security/database/破坏性回滚,可以自行用 repo-owned GitHub merge/close 路径完成收口并报告 SHA。高风险、边界不清、checks 失败或用户/指挥官保留 final action 的 PR 仍必须交回 commander 审查。host commander 也不把直接编辑业务代码当成常规 PR 替代路径。
|
||||
|
||||
PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 支持 `gh pr list|view|create|update|comment create|comment delete|close|reopen`,其中 create 需要显式 `--title`、`--base`、`--head` 和正文来源,update 需要显式 PR number、正文来源和 `--mode replace|append`,comment create 需要显式 PR number 和正文来源,且推荐使用 `--body-file`。PR 收口观察应使用 `gh pr view <number> --json state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 获取普通关闭/已合并区分、merge commit、目标分支、源分支、mergeability 和检查汇总;该路径仍是只读元数据,不执行 merge。`pr create --dry-run`、`pr update --dry-run` 与 `pr comment create --dry-run` 只返回 planned operation,不创建 PR、不更新正文、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,append 更新会先读取当前 PR body。`gh pr delete` 和 `gh pr merge` 仍是后续范围,本阶段没有 `--confirm` 可以启用真实 merge,也不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。普通 worker 不应隐式依赖未实现的 PR 合并能力;需要 PR 交付时,prompt 必须明确允许的人工或后续工具路径,并报告未覆盖范围。
|
||||
PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 支持 `gh pr list|view|create|update|comment create|comment delete|close|reopen`,其中 create 需要显式 `--title`、`--base`、`--head` 和正文来源,update 需要显式 PR number、正文来源和 `--mode replace|append`,comment create 需要显式 PR number 和正文来源,且推荐使用 `--body-file`。PR 收口观察应使用 `gh pr view <number> --json state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 获取普通关闭/已合并区分、merge commit、目标分支、源分支、mergeability 和检查汇总;该路径仍是只读元数据,不执行 merge。`pr create --dry-run`、`pr update --dry-run` 与 `pr comment create --dry-run` 只返回 planned operation,不创建 PR、不更新正文、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,append 更新会先读取当前 PR body。`gh pr delete` 和 `gh pr merge` 在 UniDesk REST CLI 中仍返回 `unsupported-command`,没有 `--confirm` 可以启用真实 merge,也不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。获得收口授权的 GPT-5.5 runner 可使用系统 `gh pr merge`、GitHub UI 或等价 repo-owned GitHub merge path 处理普通 PR;普通 worker 不应隐式依赖未实现的 UniDesk REST CLI merge 能力。需要 PR 交付时,prompt 必须明确允许的人工、runner 或后续工具路径,并报告未覆盖范围。
|
||||
|
||||
### PR 驱动派单模板
|
||||
|
||||
@@ -192,9 +192,9 @@ PR 型派单 prompt 至少包含以下字段:
|
||||
- `目标 issue`:写明 issue 编号、URL 和本次任务要关闭或推进的验收点;不要假设 runner 能读取 issue。
|
||||
- `目标分支`:明确 `master` 或经批准的维护分支;普通开发集成使用 `master`。
|
||||
- `head branch`:使用可追踪命名,例如 `code-queue/issue-35-pr-dry-run-probe`、`code-queue/issue-<number>-<short-topic>` 或包含 task id 的等价短名;head branch 必须从最新 `origin/<目标分支>` 创建。
|
||||
- `禁止动作`:禁止直接 push 目标分支,禁止 merge PR,禁止改 release/v1 运行态服务,禁止输出 token,禁止跑本任务未要求的重型 check/e2e/Playwright。
|
||||
- `禁止动作`:禁止直接 push 目标分支;除非 prompt 明确授予普通 PR 收口权,否则禁止 merge PR;禁止改 release/v1 运行态服务,禁止输出 token,禁止跑本任务未要求的重型 check/e2e/Playwright。
|
||||
- `必须动作`:提交前运行轻量自测;push head branch;创建面向目标分支的 PR;PR body 写清关联 issue、修改文件、验证命令和风险;若创建 PR 前需要探测,先运行只读或 dry-run preflight。
|
||||
- `final response 字段`:实际分支、目标分支、head branch、PR URL、远端 head commit、是否已创建 PR、是否未 merge、修改文件、验证命令和结果、遗留风险;如果 PR 未能创建,报告结构化原因和 runnerDisposition。
|
||||
- `final response 字段`:实际分支、目标分支、head branch、PR URL、远端 head commit、是否已创建 PR、merge/close 状态和 SHA 或未 merge 原因、修改文件、验证命令和结果、遗留风险;如果 PR 未能创建,报告结构化原因和 runnerDisposition。
|
||||
|
||||
PR 型 prompt 可直接嵌入以下 Git 指令约束:
|
||||
|
||||
@@ -203,9 +203,9 @@ Git/PR 交付要求:
|
||||
- 目标分支:master。
|
||||
- 从最新 origin/master 创建 head branch:code-queue/issue-<number>-<short-topic>。
|
||||
- 不得直接 push master 或 release/v1。
|
||||
- 必须 push head branch 并创建 PR 到 master;不得 merge PR。
|
||||
- 必须 push head branch 并创建 PR 到 master;除非本 prompt 明确授权普通 PR 收口,否则不得 merge PR。
|
||||
- 创建 PR 前先运行只读/dry-run preflight,确认 GH_TOKEN/GITHUB_TOKEN、GitHub egress 和 repo 可见性,不得打印 token。
|
||||
- final response 必须报告 head branch、PR URL、远端 head commit、修改文件、验证命令、是否未 merge。
|
||||
- final response 必须报告 head branch、PR URL、远端 head commit、修改文件、验证命令、merge/close 状态和 SHA 或未 merge 原因。
|
||||
```
|
||||
|
||||
Runner preflight 优先使用执行面诊断入口:
|
||||
@@ -246,10 +246,10 @@ bun scripts/cli.ts codex pr-preflight --remote --issue <issue-number>
|
||||
|
||||
- PR base 是声明的目标分支,head branch 命名可追踪,远端 head commit 可 fetch。
|
||||
- diff 只覆盖派单 ownership,未混入 release/v1 运行态服务或无关 dirty worktree。
|
||||
- PR body 和 final response 都包含关联 issue、修改文件、验证证据、未完成风险和未 merge 状态。
|
||||
- contract/dry-run 证据覆盖本次 PR 能力:`pr create --dry-run`、`pr list/view`、`pr comment --dry-run`,并确认 `gh pr merge` 返回结构化 unsupported。
|
||||
- PR body 和 final response 都包含关联 issue、修改文件、验证证据、未完成风险和 merge/close 状态。
|
||||
- contract/dry-run 证据覆盖本次 PR 能力:`pr create --dry-run`、`pr list/view`、`pr comment --dry-run`,并确认 UniDesk REST `gh pr merge` 返回结构化 unsupported;若 runner 被授权最终 merge/close,还要报告使用的 repo-owned GitHub 路径和结果 SHA。
|
||||
- 没有 token、凭证、临时日志或构建产物进入 commit、PR body 或评论。
|
||||
- 合并前由指挥官审查并决定是否 merge;合并后再验证目标分支远端 commit 可见,并按态势更新 issue/#20/#24。
|
||||
- 未授权 runner 收口的 PR 由指挥官审查并决定是否 merge;已授权 runner 收口的普通 PR 在合并后仍要验证目标分支远端 commit 可见,并按态势更新 issue/#20/#24。
|
||||
|
||||
## 监控
|
||||
|
||||
@@ -259,7 +259,9 @@ bun scripts/cli.ts codex pr-preflight --remote --issue <issue-number>
|
||||
|
||||
- `bun scripts/cli.ts codex tasks --view supervisor --limit N`:查看默认低噪声监督视图,包括 `activeRunning`、running、完成未读、少量最近完成、queued/runnable、activity、commanderConcurrency、execution diagnostics、任务分类和下一步 drill-down 命令。默认行只保留 task id、队列、短 prompt/body 预览和原始字符数;`--limit` 是扫描/分页预算,不是返回几十条肥行的开关,CLI effective limit 安全上限为 100,输出必须用 `filters.requestedLimit`、`filters.effectiveLimit`、`filters.limitCapped`、`source.requestedLimit` 和 `source.effectiveLimit` 区分用户请求、CLI cap 和 overview 源拉取预算;例如 `--limit 260` 应明确显示 requested=260、effective=100、source=200,`running.returned` 只是低噪声返回行数。`show/detail/trace/output/full/read` 放在 section template 中,避免每条任务重复刷屏,需要更多内容再按 taskId 展开。
|
||||
- `bun scripts/cli.ts codex queues`:查看低噪声队列计数、activity、commanderConcurrency、active task id、完成未读队列、runnable 队列和控制面诊断;需要完整队列行视图时加 `--full`,但 `--full` 仍默认分页,继续用 `--limit N`、`--page N` 或 `--offset N` 渐进展开。summary 和 full 都使用稳定 JSON path `.data.queues.items[]` 读取队列行,并从 `.data.queues.commanderConcurrency`、`.data.queues.activity`、`.data.queues.counts` 与 `.data.queues.executionDiagnostics` 读取全局活跃计数和执行诊断;完整 upstream 只通过输出中的 raw command 显式获取。
|
||||
- `bun scripts/cli.ts codex tasks --unread --limit N`:查看完成未读审阅积压;`--unread` 与 `--unread-only` 等价,不能被静默忽略。
|
||||
- `bun scripts/cli.ts codex unread --limit N`:查看完成未读审阅积压的默认 triage,按 repo、issue、status 和 queue 汇总,并给出有界最新任务和 drill-down/read 命令;默认不输出 raw prompt、final response、trace 或 output。
|
||||
- `bun scripts/cli.ts codex unread mark-read --repo owner/name --issue N --limit N --confirm`:批量已读入口,必须显式 `mark-read` 和 `--confirm`,否则结构化失败且不 POST `/read`。
|
||||
- `bun scripts/cli.ts codex tasks --unread --limit N`:兼容查看完成未读审阅积压;`--unread` 与 `--unread-only` 等价,不能被静默忽略。
|
||||
- `bun scripts/cli.ts codex tasks --status succeeded --unread --limit N`:按具体终态过滤监督结果;不支持的 status filter 必须显式失败,不能扩大为未过滤结果。
|
||||
- `bun scripts/cli.ts codex task <taskId>`:默认只查看原始 prompt、最终 response、最后错误和 drill-down 命令,这是完成未读任务审阅的第一步。
|
||||
- 当默认审阅摘要不足时,再逐级使用 `bun scripts/cli.ts codex task <taskId> --detail`、`bun scripts/cli.ts codex task <taskId> --trace --limit N` 或 `codex output`。
|
||||
|
||||
@@ -56,7 +56,7 @@ ls -t /root/unidesk/logs/commander.log.*.jsonl | head -n 1
|
||||
|
||||
## 指挥职责
|
||||
|
||||
host commander 的工作是调度、监督、steer、审阅和 PR 收口;它维护并发窗口、阻塞分类、issue/#20/#24 记录,并在 checks 和审阅通过后负责合并裁决或走已批准的合并路径。Code Queue runner 的工作是实现、验证、提交、push head branch 和创建 PR;runner 不合并自己的 PR。
|
||||
host commander 的工作是调度、监督、steer、审阅和 PR 收口;它维护并发窗口、阻塞分类、issue/#20/#24 记录,并在 checks 和审阅通过后负责合并裁决或走已批准的合并路径。Code Queue runner 的工作通常是实现、验证、提交、push head branch 和创建 PR;当 GPT-5.5 runner 的 prompt 明确包含普通 PR 收口授权,且 PR 不涉及 prod/runtime/release/security/database/破坏性回滚、无冲突并通过任务要求检查时,runner 可以自行用 repo-owned GitHub merge/close 路径完成收口并报告 SHA。高风险、边界不清或用户/指挥官保留 final action 的 PR 仍由 host commander 审查。
|
||||
|
||||
HWLAB 业务目标、验收和优先级属于 `pikasTech/HWLAB#7`;UniDesk 指挥治理、队列监督、并发窗口和 runner/PR handoff 属于 `pikasTech/unidesk#20`。#20 可以链接 HWLAB #7 的证据和状态,但不能替代 HWLAB 业务 issue,也不能把 HWLAB 代码实现决策写成 UniDesk 指挥规则。
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { codexUnreadTriageForTest } from "./src/code-queue";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RequestRecord = { path: string; method: string; body: unknown };
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), "expected JSON object", { value });
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
assertCondition(Array.isArray(value), "expected JSON array", { value });
|
||||
return value as unknown[];
|
||||
}
|
||||
|
||||
function task(id: string, queueId: string, status: string, updatedAt: string, readAt: string | null, prompt: string): JsonRecord {
|
||||
return {
|
||||
id,
|
||||
queueId,
|
||||
status,
|
||||
currentAttempt: 1,
|
||||
updatedAt,
|
||||
finishedAt: status === "running" ? null : updatedAt,
|
||||
readAt,
|
||||
displayPrompt: prompt,
|
||||
basePrompt: prompt,
|
||||
prompt,
|
||||
cwd: "/root/unidesk",
|
||||
referenceTaskIds: [],
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function fixtureResponse(path: string, init?: { method?: string; body?: unknown }, requests: RequestRecord[] = []): JsonRecord {
|
||||
const method = init?.method ?? "GET";
|
||||
requests.push({ path, method, body: init?.body });
|
||||
if (path.endsWith("/read")) {
|
||||
const taskId = decodeURIComponent(path.split("/api/tasks/")[1]?.split("/")[0] ?? "unknown");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
task: task(taskId, "default", "succeeded", "2026-05-22T00:10:00.000Z", "2026-05-22T00:10:01.000Z", "read response prompt should stay compact"),
|
||||
queue: { counts: { succeeded: 1 }, unreadTerminal: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
assertCondition(path.startsWith("/api/microservices/code-queue/proxy/api/tasks/overview"), "unexpected path", { path, method });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
ok: true,
|
||||
queue: { unreadTerminal: 4 },
|
||||
pagination: {
|
||||
limit: 200,
|
||||
returned: 6,
|
||||
total: 6,
|
||||
hasMore: false,
|
||||
nextBeforeId: null,
|
||||
includeActive: true,
|
||||
},
|
||||
tasks: [
|
||||
task("task-new-1", "default", "succeeded", "2026-05-22T00:05:00.000Z", null, "pikasTech/unidesk#20 closeout RAW_PROMPT_SHOULD_NOT_LEAK"),
|
||||
task("task-new-2", "review", "failed", "2026-05-22T00:04:00.000Z", null, "https://github.com/pikasTech/unidesk/issues/20 failed task RAW_PROMPT_SHOULD_NOT_LEAK"),
|
||||
task("task-new-3", "review", "canceled", "2026-05-22T00:03:00.000Z", null, "pikasTech/unidesk#21 canceled task RAW_PROMPT_SHOULD_NOT_LEAK"),
|
||||
task("task-unknown", "misc", "succeeded", "2026-05-22T00:02:00.000Z", null, "no repo marker RAW_PROMPT_SHOULD_NOT_LEAK"),
|
||||
task("task-read", "default", "succeeded", "2026-05-22T00:01:00.000Z", "2026-05-22T00:01:01.000Z", "pikasTech/unidesk#20 already read RAW_PROMPT_SHOULD_NOT_LEAK"),
|
||||
task("task-running", "default", "running", "2026-05-22T00:00:00.000Z", null, "pikasTech/unidesk#20 running RAW_PROMPT_SHOULD_NOT_LEAK"),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countItems(bucket: JsonRecord): JsonRecord[] {
|
||||
return asArray(bucket.items).map(asRecord);
|
||||
}
|
||||
|
||||
function itemCount(bucket: JsonRecord, key: string): number {
|
||||
const row = countItems(bucket).find((item) => item.key === key);
|
||||
return typeof row?.count === "number" ? row.count : 0;
|
||||
}
|
||||
|
||||
export function runCodeQueueUnreadTriageContract(): JsonRecord {
|
||||
const requests: RequestRecord[] = [];
|
||||
const fetcher = (path: string, init?: { method?: string; body?: unknown }): JsonRecord => fixtureResponse(path, init, requests);
|
||||
|
||||
const summary = codexUnreadTriageForTest(["--limit", "2"], fetcher);
|
||||
const summaryBody = JSON.stringify(summary);
|
||||
const triage = asRecord(asRecord(summary).unreadTriage);
|
||||
const counts = asRecord(triage.counts);
|
||||
const newest = asRecord(triage.newest);
|
||||
const commands = asRecord(triage.commands);
|
||||
|
||||
assertCondition(asRecord(summary).ok === true, "default unread triage should succeed", asRecord(summary));
|
||||
assertCondition(triage.readOnly === true && triage.bounded === true, "default unread triage must be read-only and bounded", triage);
|
||||
assertCondition(counts.totalUnreadTerminal === 4, "counts should include only unread terminal tasks", counts);
|
||||
assertCondition(itemCount(asRecord(counts.byRepo), "pikasTech/unidesk") === 3, "repo counts should group owner/name refs", counts);
|
||||
assertCondition(itemCount(asRecord(counts.byIssue), "#20") === 2, "issue counts should group issue refs", counts);
|
||||
assertCondition(itemCount(asRecord(counts.byStatus), "succeeded") === 2, "status counts should include terminal statuses", counts);
|
||||
assertCondition(itemCount(asRecord(counts.byQueue), "review") === 2, "queue counts should include queues", counts);
|
||||
assertCondition(newest.returned === 2 && newest.hasMore === true, "newest items should obey --limit and expose pagination", newest);
|
||||
assertCondition(typeof commands.perTaskRead === "string" && String(commands.perTaskRead).includes("codex read <taskId>"), "triage should preserve per-task read drill-down", commands);
|
||||
assertCondition(!summaryBody.includes("RAW_PROMPT_SHOULD_NOT_LEAK"), "triage output must not dump raw prompt text", { summaryBody });
|
||||
assertCondition(!requests.some((request) => request.path.includes("/summary")), "triage must not fetch per-task summaries by default", { requests });
|
||||
assertCondition(!requests.some((request) => request.method === "POST"), "default triage must not mutate", { requests });
|
||||
|
||||
const guardStart = requests.length;
|
||||
const guarded = codexUnreadTriageForTest(["mark-read", "--repo", "pikasTech/unidesk", "--issue", "20", "--limit", "2"], fetcher);
|
||||
const guardedTriage = asRecord(asRecord(guarded).unreadTriage);
|
||||
const guardedMutation = asRecord(guardedTriage.mutation);
|
||||
assertCondition(asRecord(guarded).ok === false, "batch mark-read without --confirm should fail closed", asRecord(guarded));
|
||||
assertCondition(guardedMutation.blocked === true && guardedMutation.confirmed === false, "confirm guard should describe blocked mutation", guardedMutation);
|
||||
assertCondition(!requests.slice(guardStart).some((request) => request.method === "POST"), "missing --confirm must not POST read calls", { requests: requests.slice(guardStart) });
|
||||
|
||||
const confirmStart = requests.length;
|
||||
const confirmed = codexUnreadTriageForTest(["mark-read", "--repo", "pikasTech/unidesk", "--issue", "20", "--limit", "2", "--confirm"], fetcher);
|
||||
const confirmedTriage = asRecord(asRecord(confirmed).unreadTriage);
|
||||
const confirmedMutation = asRecord(confirmedTriage.mutation);
|
||||
const readPosts = requests.slice(confirmStart).filter((request) => request.method === "POST" && request.path.endsWith("/read"));
|
||||
assertCondition(asRecord(confirmed).ok === true, "confirmed batch mark-read should succeed", asRecord(confirmed));
|
||||
assertCondition(confirmedTriage.readOnly === false && confirmedMutation.confirmed === true, "confirmed mutation should be explicit", confirmedMutation);
|
||||
assertCondition(confirmedMutation.attempted === 2 && confirmedMutation.succeeded === 2, "confirmed mutation should respect filters and limit", confirmedMutation);
|
||||
assertCondition(readPosts.length === 2 && readPosts[0]?.path.includes("task-new-1") && readPosts[1]?.path.includes("task-new-2"), "confirmed mutation should POST newest filtered task reads only", { readPosts });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
checks: [
|
||||
"default unread triage is read-only",
|
||||
"repo/issue/status/queue counts are present",
|
||||
"newest items are bounded",
|
||||
"raw prompt text is omitted",
|
||||
"batch mark-read requires --confirm",
|
||||
"confirmed batch read respects filters and limit",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
process.stdout.write(`${JSON.stringify(runCodeQueueUnreadTriageContract(), null, 2)}\n`);
|
||||
}
|
||||
@@ -523,6 +523,9 @@ export async function runGhCliPrContract(): Promise<JsonRecord> {
|
||||
const mergeData = mergeBlocked.json?.data as JsonRecord | undefined;
|
||||
assertCondition(String(mergeData?.message ?? "").includes("intentionally unsupported"), "merge block message should be explicit", mergeData ?? {});
|
||||
assertCondition(mergeData?.runnerDisposition === "business-failed", "merge block should classify as business-failed", mergeData ?? {});
|
||||
const closeoutBoundary = mergeData?.closeoutBoundary as JsonRecord | undefined;
|
||||
assertCondition(closeoutBoundary?.ordinaryRunnerFinalActionAllowed === true, "merge block should preserve ordinary runner PR closeout policy", closeoutBoundary ?? {});
|
||||
assertCondition(closeoutBoundary?.unideskCliMergeSupported === false, "merge block should state UniDesk REST CLI merge remains unsupported", closeoutBoundary ?? {});
|
||||
|
||||
const deleteBlocked = await runCli(["gh", "pr", "delete", "42", "--repo", "pikasTech/unidesk"]);
|
||||
assertCondition(deleteBlocked.status !== 0, "pr hard delete should fail", deleteBlocked.json ?? { stdout: deleteBlocked.stdout });
|
||||
|
||||
@@ -50,6 +50,7 @@ for (const expected of [
|
||||
"trace-summary-plan",
|
||||
"issue-20-board-read-write-entry",
|
||||
"issue-46-brief-read-write-entry",
|
||||
"pr-closeout-boundary-plan",
|
||||
"claudeqq-high-risk-approval-entry",
|
||||
]) {
|
||||
assertCondition(capabilities.includes(expected), `missing required capability ${expected}`, capabilities);
|
||||
@@ -70,6 +71,10 @@ assertCondition(asRecord(asRecord(plan.processDiscovery, "processDiscovery").sta
|
||||
assertCondition(asRecord(plan.bridge, "bridge").mutation === false, "bridge plan must not open bridges", plan);
|
||||
assertCondition(asRecord(plan.traceSummary, "traceSummary").mutation === false, "trace summary plan must be non-mutating", plan);
|
||||
assertCondition(asRecord(plan.issueEntries, "issueEntries").mutation === false, "issue entry plan must be non-mutating", plan);
|
||||
const prCloseout = asRecord(plan.prCloseout, "prCloseout");
|
||||
assertCondition(prCloseout.mutation === false, "PR closeout plan must be non-mutating", prCloseout);
|
||||
assertCondition(asRecord(prCloseout.runnerBoundary, "runnerBoundary").maySelfCloseOrMergeOrdinaryPrWithinTaskBoundary === true, "ordinary PR runner self-close/merge boundary must be explicit", prCloseout);
|
||||
assertCondition(asRecord(prCloseout.unideskCliBoundary, "unideskCliBoundary").mergeSupported === false, "UniDesk REST gh pr merge must remain unsupported", prCloseout);
|
||||
assertCondition(asRecord(plan.claudeqqApproval, "claudeqqApproval").mutation === false, "approval plan must be non-mutating", plan);
|
||||
|
||||
const planWithoutDryRun = dataOf(runCli(["commander", "plan"], 1));
|
||||
|
||||
+405
-5
@@ -20,6 +20,7 @@ const supervisorRecentCompletedLimit = 3;
|
||||
const supervisorPromptPreviewChars = 70;
|
||||
const supervisorBodyPreviewChars = 70;
|
||||
const supervisorRecentBodyPreviewChars = 50;
|
||||
const unreadTriageCountLimit = 12;
|
||||
const diagnosticsIdPreviewLimit = 3;
|
||||
const diagnosticsReasonPreviewLimit = 2;
|
||||
const mutationQueueIdPreviewLimit = 15;
|
||||
@@ -269,6 +270,21 @@ interface CodexTasksOptions {
|
||||
view: "supervisor" | "full";
|
||||
}
|
||||
|
||||
type CodexUnreadAction = "summary" | "mark-read";
|
||||
|
||||
interface CodexUnreadOptions {
|
||||
queueId: string | undefined;
|
||||
requestedLimit: number;
|
||||
limit: number;
|
||||
beforeId: string | undefined;
|
||||
statusFilter: string[] | null;
|
||||
repoFilter: string | undefined;
|
||||
issueFilter: string | undefined;
|
||||
action: CodexUnreadAction;
|
||||
confirm: boolean;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
interface CodexTasksEntry {
|
||||
taskId: string;
|
||||
queueId: string | null;
|
||||
@@ -2180,6 +2196,64 @@ function parseTasksOptions(args: string[]): CodexTasksOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepoFilter(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim()
|
||||
.replace(/^https?:\/\/github\.com\//iu, "")
|
||||
.replace(/\.git$/iu, "")
|
||||
.replace(/^\/+|\/+$/gu, "");
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(trimmed)) {
|
||||
throw new Error(`--repo must be owner/name; got ${value}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeIssueFilter(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim().replace(/^#/u, "");
|
||||
const number = Number(trimmed);
|
||||
if (!Number.isInteger(number) || number <= 0) throw new Error(`--issue must be a positive issue number; got ${value}`);
|
||||
return `#${number}`;
|
||||
}
|
||||
|
||||
function parseUnreadOptions(args: string[]): CodexUnreadOptions {
|
||||
const first = args[0];
|
||||
const hasSubcommand = first !== undefined && !first.startsWith("--");
|
||||
const subcommand = hasSubcommand ? first : "summary";
|
||||
if (subcommand !== "summary" && subcommand !== "list" && subcommand !== "mark-read") {
|
||||
throw new Error(`codex unread subcommand must be summary, list, or mark-read; got ${subcommand}`);
|
||||
}
|
||||
const optionArgs = hasSubcommand ? args.slice(1) : args;
|
||||
assertKnownOptions(optionArgs, {
|
||||
flags: ["--mark-read", "--confirm", "--dry-run"],
|
||||
valueOptions: ["--queue", "--queue-id", "--limit", "--status", "--repo", "--issue", "--before-id", "--beforeId"],
|
||||
}, "codex unread");
|
||||
const statusRaw = optionValue(optionArgs, ["--status"]);
|
||||
const statusFilter = statusRaw === undefined
|
||||
? null
|
||||
: statusRaw.split(/[,\s]+/u).map((item) => item.trim()).filter(Boolean);
|
||||
if (statusFilter !== null) {
|
||||
const supported = new Set(["succeeded", "failed", "canceled"]);
|
||||
const unsupported = statusFilter.filter((status) => !supported.has(status));
|
||||
if (unsupported.length > 0) throw new Error(`unsupported --status value for codex unread: ${unsupported.join(", ")}; supported terminal statuses: ${Array.from(supported).join(", ")}`);
|
||||
}
|
||||
const requestedLimit = positiveIntegerOption(optionArgs, ["--limit"], defaultTasksLimit);
|
||||
const action: CodexUnreadAction = subcommand === "mark-read" || hasFlag(optionArgs, "--mark-read") ? "mark-read" : "summary";
|
||||
const explicitDryRun = hasFlag(optionArgs, "--dry-run");
|
||||
return {
|
||||
queueId: optionValue(optionArgs, ["--queue", "--queue-id"]),
|
||||
requestedLimit,
|
||||
limit: Math.min(requestedLimit, maxTasksLimit),
|
||||
beforeId: optionValue(optionArgs, ["--before-id", "--beforeId"]),
|
||||
statusFilter,
|
||||
repoFilter: normalizeRepoFilter(optionValue(optionArgs, ["--repo"])),
|
||||
issueFilter: normalizeIssueFilter(optionValue(optionArgs, ["--issue"])),
|
||||
action,
|
||||
confirm: hasFlag(optionArgs, "--confirm"),
|
||||
dryRun: explicitDryRun || action === "summary",
|
||||
};
|
||||
}
|
||||
|
||||
function parseQueuesOptions(args: string[]): CodexQueuesOptions {
|
||||
assertKnownOptions(args, {
|
||||
flags: ["--full", "--all"],
|
||||
@@ -2983,6 +3057,290 @@ function loadCodexTasks(taskArgs: CodexTasksOptions, fetcher: CodexResponseFetch
|
||||
return { upstream: response.upstream, page: { queue: asRecord(response.body.queue), pagination: asRecord(response.body.pagination) ?? {}, tasks } };
|
||||
}
|
||||
|
||||
function unreadLoadOptions(options: CodexUnreadOptions): CodexTasksOptions {
|
||||
return {
|
||||
queueId: options.queueId,
|
||||
requestedLimit: maxTasksLimit,
|
||||
limit: maxTasksLimit,
|
||||
beforeId: undefined,
|
||||
unreadOnly: true,
|
||||
statusFilter: null,
|
||||
view: "full",
|
||||
};
|
||||
}
|
||||
|
||||
function unreadTriageCommand(options: CodexUnreadOptions, action: CodexUnreadAction = "summary", extra: string[] = []): string {
|
||||
const args = ["codex", "unread"];
|
||||
if (action === "mark-read") args.push("mark-read");
|
||||
if (options.queueId !== undefined) args.push("--queue", options.queueId);
|
||||
if (options.repoFilter !== undefined) args.push("--repo", options.repoFilter);
|
||||
if (options.issueFilter !== undefined) args.push("--issue", options.issueFilter.replace(/^#/u, ""));
|
||||
if (options.statusFilter !== null) args.push("--status", options.statusFilter.join(","));
|
||||
if (options.requestedLimit !== defaultTasksLimit) args.push("--limit", String(options.requestedLimit));
|
||||
if (options.beforeId !== undefined) args.push("--before-id", options.beforeId);
|
||||
args.push(...extra);
|
||||
return `bun scripts/cli.ts ${args.join(" ")}`;
|
||||
}
|
||||
|
||||
function taskTriageSearchText(task: Record<string, unknown>): string {
|
||||
return [
|
||||
asString(task.displayPrompt),
|
||||
asString(task.basePrompt),
|
||||
asString(task.prompt),
|
||||
asString(task.cwd),
|
||||
asString(task.lastError),
|
||||
...stringList(task.referenceTaskIds),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function taskRepoRefs(task: Record<string, unknown>): string[] {
|
||||
const text = taskTriageSearchText(task);
|
||||
const repos = new Set<string>();
|
||||
for (const match of text.matchAll(/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#\d{1,6}\b/gu)) {
|
||||
const repo = match[1] ?? "";
|
||||
if (repo.length > 0) repos.add(repo);
|
||||
}
|
||||
for (const match of text.matchAll(/\bgithub\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:[#/\s?]|$)/giu)) {
|
||||
const owner = match[1] ?? "";
|
||||
const name = (match[2] ?? "").replace(/\.git$/iu, "");
|
||||
if (owner.length > 0 && name.length > 0) repos.add(`${owner}/${name}`);
|
||||
}
|
||||
return Array.from(repos).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function taskIssueRefsForTriage(task: Record<string, unknown>): string[] {
|
||||
const text = taskTriageSearchText(task);
|
||||
const issues = new Set<string>();
|
||||
for (const match of text.matchAll(/#(\d{1,6})\b/gu)) issues.add(`#${match[1]}`);
|
||||
for (const match of text.matchAll(/\/issues\/(\d{1,6})\b/gu)) issues.add(`#${match[1]}`);
|
||||
return Array.from(issues)
|
||||
.sort((left, right) => Number(left.slice(1)) - Number(right.slice(1)));
|
||||
}
|
||||
|
||||
function lowerSet(values: string[]): Set<string> {
|
||||
return new Set(values.map((value) => value.toLowerCase()));
|
||||
}
|
||||
|
||||
function taskMatchesUnreadFilters(task: Record<string, unknown>, options: CodexUnreadOptions): boolean {
|
||||
if (!taskUnreadTerminal(task)) return false;
|
||||
if (!taskMatchesStatusFilter(task, options.statusFilter)) return false;
|
||||
if (options.queueId !== undefined && asString(task.queueId) !== options.queueId) return false;
|
||||
if (options.repoFilter !== undefined && !lowerSet(taskRepoRefs(task)).has(options.repoFilter.toLowerCase())) return false;
|
||||
if (options.issueFilter !== undefined && !taskIssueRefsForTriage(task).includes(options.issueFilter)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function unreadSortedCandidates(tasks: Record<string, unknown>[], options: CodexUnreadOptions): Record<string, unknown>[] {
|
||||
return sortCompletedWatchTasks(tasks.filter((task) => taskMatchesUnreadFilters(task, options)));
|
||||
}
|
||||
|
||||
function unreadPageCandidates(candidates: Record<string, unknown>[], options: CodexUnreadOptions): Record<string, unknown>[] {
|
||||
if (options.beforeId === undefined) return candidates;
|
||||
const beforeIndex = candidates.findIndex((task) => taskOverviewCandidateKey(task) === options.beforeId);
|
||||
return beforeIndex < 0 ? candidates : candidates.slice(beforeIndex + 1);
|
||||
}
|
||||
|
||||
function incrementCount(map: Map<string, number>, key: string): void {
|
||||
map.set(key, (map.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
function countBucket(map: Map<string, number>, limit = unreadTriageCountLimit): Record<string, unknown> {
|
||||
const rows = Array.from(map.entries())
|
||||
.map(([key, count]) => ({ key, count }))
|
||||
.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key));
|
||||
return {
|
||||
totalDistinct: rows.length,
|
||||
returned: Math.min(rows.length, limit),
|
||||
truncated: rows.length > limit,
|
||||
items: rows.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
function unreadTriageCounts(candidates: Record<string, unknown>[]): Record<string, unknown> {
|
||||
const byRepo = new Map<string, number>();
|
||||
const byIssue = new Map<string, number>();
|
||||
const byStatus = new Map<string, number>();
|
||||
const byQueue = new Map<string, number>();
|
||||
for (const task of candidates) {
|
||||
incrementCount(byStatus, asString(task.status) || "unknown");
|
||||
incrementCount(byQueue, asString(task.queueId) || "unknown");
|
||||
const repos = taskRepoRefs(task);
|
||||
const issues = taskIssueRefsForTriage(task);
|
||||
if (repos.length === 0) incrementCount(byRepo, "unknown");
|
||||
for (const repo of repos) incrementCount(byRepo, repo);
|
||||
if (issues.length === 0) incrementCount(byIssue, "unknown");
|
||||
for (const issue of issues) incrementCount(byIssue, issue);
|
||||
}
|
||||
return {
|
||||
totalUnreadTerminal: candidates.length,
|
||||
byRepo: countBucket(byRepo),
|
||||
byIssue: countBucket(byIssue),
|
||||
byStatus: countBucket(byStatus),
|
||||
byQueue: countBucket(byQueue),
|
||||
};
|
||||
}
|
||||
|
||||
function unreadTriageItem(task: Record<string, unknown>): Record<string, unknown> {
|
||||
const taskId = taskOverviewCandidateKey(task);
|
||||
return {
|
||||
id: taskId,
|
||||
queue: asString(task.queueId) || null,
|
||||
status: asString(task.status) || null,
|
||||
repos: taskRepoRefs(task),
|
||||
issues: taskIssueRefsForTriage(task),
|
||||
updatedAt: asString(task.updatedAt) || null,
|
||||
finishedAt: asString(task.finishedAt) || null,
|
||||
commands: {
|
||||
show: `bun scripts/cli.ts codex task ${taskId}`,
|
||||
detail: `bun scripts/cli.ts codex task ${taskId} --detail`,
|
||||
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
|
||||
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
|
||||
read: `bun scripts/cli.ts codex read ${taskId}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function unreadTriageSummary(upstream: { ok: unknown; status: unknown }, page: CodexTasksTaskPage, options: CodexUnreadOptions): {
|
||||
result: Record<string, unknown>;
|
||||
visibleCandidates: Record<string, unknown>[];
|
||||
} {
|
||||
const allCandidates = unreadSortedCandidates(page.tasks, options);
|
||||
const pagedCandidates = unreadPageCandidates(allCandidates, options);
|
||||
const visibleCandidates = pagedCandidates.slice(0, options.limit);
|
||||
const hasMore = pagedCandidates.length > visibleCandidates.length;
|
||||
const nextBeforeId = hasMore ? taskOverviewCandidateKey(visibleCandidates.at(-1) ?? {}) || null : null;
|
||||
const nextCommand = nextBeforeId === null ? null : unreadTriageCommand({ ...options, beforeId: nextBeforeId });
|
||||
const readCommand = unreadTriageCommand(options, "mark-read", ["--confirm"]);
|
||||
return {
|
||||
visibleCandidates,
|
||||
result: {
|
||||
ok: true,
|
||||
upstream,
|
||||
unreadTriage: {
|
||||
filters: {
|
||||
queueId: options.queueId ?? null,
|
||||
repo: options.repoFilter ?? null,
|
||||
issue: options.issueFilter ?? null,
|
||||
status: options.statusFilter,
|
||||
requestedLimit: options.requestedLimit,
|
||||
limit: options.limit,
|
||||
limitCapped: options.requestedLimit > options.limit,
|
||||
beforeId: options.beforeId ?? null,
|
||||
},
|
||||
readOnly: options.action === "summary" || options.dryRun,
|
||||
bounded: true,
|
||||
disclosure: {
|
||||
policy: "summary counts plus bounded task ids only; no raw prompt, final response, trace, or output is included by default",
|
||||
countBucketLimit: unreadTriageCountLimit,
|
||||
itemLimit: options.limit,
|
||||
mutationPolicy: "batch mark-read requires codex unread mark-read --confirm; per-task read remains codex read <taskId>",
|
||||
},
|
||||
counts: unreadTriageCounts(allCandidates),
|
||||
newest: {
|
||||
count: allCandidates.length,
|
||||
returned: visibleCandidates.length,
|
||||
hasMore,
|
||||
nextBeforeId,
|
||||
commands: {
|
||||
next: nextCommand,
|
||||
showTemplate: "bun scripts/cli.ts codex task <taskId>",
|
||||
detailTemplate: "bun scripts/cli.ts codex task <taskId> --detail",
|
||||
traceTemplate: `bun scripts/cli.ts codex task <taskId> --trace --tail --limit ${defaultTraceLimit}`,
|
||||
outputTemplate: `bun scripts/cli.ts codex output <taskId> --tail --limit ${defaultOutputLimit}`,
|
||||
readTemplate: "bun scripts/cli.ts codex read <taskId>",
|
||||
},
|
||||
items: visibleCandidates.map(unreadTriageItem),
|
||||
},
|
||||
commands: {
|
||||
refresh: unreadTriageCommand(options),
|
||||
tasksUnread: taskListCommand({
|
||||
queueId: options.queueId,
|
||||
requestedLimit: Math.min(options.requestedLimit, defaultTasksLimit),
|
||||
limit: Math.min(options.limit, defaultTasksLimit),
|
||||
beforeId: undefined,
|
||||
unreadOnly: true,
|
||||
statusFilter: options.statusFilter,
|
||||
view: "supervisor",
|
||||
}),
|
||||
byRepo: "bun scripts/cli.ts codex unread --repo owner/name",
|
||||
byIssue: "bun scripts/cli.ts codex unread --issue <number>",
|
||||
byStatus: "bun scripts/cli.ts codex unread --status succeeded|failed|canceled",
|
||||
byQueue: "bun scripts/cli.ts codex unread --queue <queueId>",
|
||||
perTaskRead: "bun scripts/cli.ts codex read <taskId>",
|
||||
batchReadDryRun: unreadTriageCommand(options, "mark-read", ["--dry-run"]),
|
||||
batchReadConfirm: readCommand,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function codexUnreadMutationGuard(result: Record<string, unknown>, visibleCandidates: Record<string, unknown>[], options: CodexUnreadOptions): Record<string, unknown> {
|
||||
const triage = asRecord(result.unreadTriage) ?? {};
|
||||
return {
|
||||
...result,
|
||||
ok: false,
|
||||
unreadTriage: {
|
||||
...triage,
|
||||
readOnly: true,
|
||||
mutation: {
|
||||
requested: true,
|
||||
confirmed: false,
|
||||
blocked: true,
|
||||
reason: "batch mark-read requires --confirm; default codex unread output is read-only",
|
||||
candidateTaskIds: visibleCandidates.map(taskOverviewCandidateKey),
|
||||
command: unreadTriageCommand(options, "mark-read", ["--confirm"]),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function codexUnreadMutationResult(result: Record<string, unknown>, options: CodexUnreadOptions, results: Record<string, unknown>[]): Record<string, unknown> {
|
||||
const triage = asRecord(result.unreadTriage) ?? {};
|
||||
const failed = results.filter((item) => item.ok === false);
|
||||
return {
|
||||
...result,
|
||||
ok: failed.length === 0,
|
||||
unreadTriage: {
|
||||
...triage,
|
||||
readOnly: false,
|
||||
mutation: {
|
||||
requested: true,
|
||||
confirmed: true,
|
||||
action: "mark-read",
|
||||
limit: options.limit,
|
||||
attempted: results.length,
|
||||
succeeded: results.length - failed.length,
|
||||
failed: failed.length,
|
||||
results,
|
||||
commands: {
|
||||
refresh: unreadTriageCommand({ ...options, action: "summary", confirm: false, dryRun: true }),
|
||||
tasksUnread: `bun scripts/cli.ts codex unread --limit ${defaultTasksLimit}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function codexUnreadTriage(taskArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
const options = parseUnreadOptions(taskArgs);
|
||||
const { upstream, page } = loadCodexTasks(unreadLoadOptions(options), fetcher);
|
||||
const { result, visibleCandidates } = unreadTriageSummary(upstream, page, options);
|
||||
if (options.action !== "mark-read" || options.dryRun) return result;
|
||||
if (!options.confirm) return codexUnreadMutationGuard(result, visibleCandidates, options);
|
||||
const results: Record<string, unknown>[] = [];
|
||||
for (const task of visibleCandidates) {
|
||||
const taskId = taskOverviewCandidateKey(task);
|
||||
try {
|
||||
const response = unwrapCodexResponse(fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/read`), { method: "POST", body: {} }));
|
||||
results.push({ taskId, ok: true, upstream: response.upstream });
|
||||
} catch (error) {
|
||||
results.push({ taskId, ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
return codexUnreadMutationResult(result, options, results);
|
||||
}
|
||||
|
||||
function codexTasksOverviewResult(
|
||||
taskPage: CodexTasksTaskPage,
|
||||
upstream: { ok: unknown; status: unknown },
|
||||
@@ -3163,6 +3521,10 @@ export function codexTasksQueryForTest(taskArgs: string[], fetcher: CodexRespons
|
||||
return codexTasksQuery(taskArgs, fetcher);
|
||||
}
|
||||
|
||||
export function codexUnreadTriageForTest(taskArgs: string[], fetcher: CodexResponseFetcher): unknown {
|
||||
return codexUnreadTriage(taskArgs, fetcher);
|
||||
}
|
||||
|
||||
async function codexTasksQueryAsync(taskArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const options = parseTasksOptions(taskArgs);
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
@@ -3186,6 +3548,41 @@ async function codexTasksQueryAsync(taskArgs: string[], fetcher: AsyncCodexRespo
|
||||
return codexTasksOverviewResult(page, response.upstream, options, summaries, degraded);
|
||||
}
|
||||
|
||||
async function codexUnreadTriageAsync(taskArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const options = parseUnreadOptions(taskArgs);
|
||||
const loadOptions = unreadLoadOptions(options);
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
const response = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/overview${tasksListQueryString(loadOptions)}`)));
|
||||
const pageTasks = asArray(response.body.tasks).map((task) => asRecord(task)).filter((task): task is Record<string, unknown> => task !== null);
|
||||
for (const task of pageTasks) {
|
||||
const taskId = taskOverviewCandidateKey(task);
|
||||
if (taskId.length === 0) continue;
|
||||
const existing = byId.get(taskId);
|
||||
if (existing === undefined || taskTimelineMs(task) >= taskTimelineMs(existing)) byId.set(taskId, task);
|
||||
}
|
||||
const tasks = Array.from(byId.values()).sort((left, right) => {
|
||||
const leftTime = taskTimelineMs(left);
|
||||
const rightTime = taskTimelineMs(right);
|
||||
if (leftTime !== rightTime) return rightTime - leftTime;
|
||||
return asString(left.id).localeCompare(asString(right.id));
|
||||
});
|
||||
const page: CodexTasksTaskPage = { queue: asRecord(response.body.queue), pagination: asRecord(response.body.pagination) ?? {}, tasks };
|
||||
const { result, visibleCandidates } = unreadTriageSummary(response.upstream, page, options);
|
||||
if (options.action !== "mark-read" || options.dryRun) return result;
|
||||
if (!options.confirm) return codexUnreadMutationGuard(result, visibleCandidates, options);
|
||||
const results: Record<string, unknown>[] = [];
|
||||
for (const task of visibleCandidates) {
|
||||
const taskId = taskOverviewCandidateKey(task);
|
||||
try {
|
||||
const readResponse = unwrapCodexResponse(await fetcher(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/read`), { method: "POST", body: {} }));
|
||||
results.push({ taskId, ok: true, upstream: readResponse.upstream });
|
||||
} catch (error) {
|
||||
results.push({ taskId, ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
return codexUnreadMutationResult(result, options, results);
|
||||
}
|
||||
|
||||
async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const summaryPath = codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`);
|
||||
const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath));
|
||||
@@ -3242,7 +3639,7 @@ export async function codexJudgeQueryAsync(taskId: string, optionArgs: string[],
|
||||
return codexTaskJudgeAsync(taskId, parseJudgeOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
export { codexQueuesQueryAsync, codexTasksQueryAsync };
|
||||
export { codexQueuesQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync };
|
||||
|
||||
function requireQueueId(args: string[], command: string): string {
|
||||
const index = args.indexOf("--queue");
|
||||
@@ -3337,7 +3734,7 @@ function compactQueueRow(value: unknown): Record<string, unknown> {
|
||||
updatedAt: record.updatedAt ?? null,
|
||||
commands: {
|
||||
tasks: record.id === undefined || record.id === null ? null : `bun scripts/cli.ts codex tasks --queue ${String(record.id)} --limit ${defaultTasksLimit}`,
|
||||
unread: record.id === undefined || record.id === null ? null : `bun scripts/cli.ts codex tasks --queue ${String(record.id)} --unread --limit ${defaultTasksLimit}`,
|
||||
unread: record.id === undefined || record.id === null ? null : `bun scripts/cli.ts codex unread --queue ${String(record.id)} --limit ${defaultTasksLimit}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3438,7 +3835,7 @@ function compactQueuesResponse(body: Record<string, unknown>, options: CodexQueu
|
||||
first: queueListCommand({ full: options.full, limit: options.limit, offset: 0 }),
|
||||
full: queueListCommand({ full: true, limit: options.limit, offset: 0 }),
|
||||
tasks: `bun scripts/cli.ts codex tasks --view supervisor --limit ${Math.min(options.limit, defaultTasksLimit)}`,
|
||||
unread: `bun scripts/cli.ts codex tasks --unread --limit ${Math.min(options.limit, defaultTasksLimit)}`,
|
||||
unread: `bun scripts/cli.ts codex unread --limit ${Math.min(options.limit, defaultTasksLimit)}`,
|
||||
raw: "bun scripts/cli.ts microservice proxy code-queue /api/queues --raw --full",
|
||||
},
|
||||
},
|
||||
@@ -4077,7 +4474,7 @@ function codexReadTaskWithFetcher(taskId: string, fetcher: CodexResponseFetcher)
|
||||
detail: `bun scripts/cli.ts codex task ${taskId} --detail`,
|
||||
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
|
||||
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
|
||||
unread: `bun scripts/cli.ts codex tasks --unread --limit ${defaultTasksLimit}`,
|
||||
unread: `bun scripts/cli.ts codex unread --limit ${defaultTasksLimit}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5633,6 +6030,9 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[])
|
||||
if (action === "tasks" || action === "overview") {
|
||||
return codexTasksQuery(args.slice(1));
|
||||
}
|
||||
if (action === "unread" || action === "terminal-unread") {
|
||||
return codexUnreadTriage(args.slice(1));
|
||||
}
|
||||
if (action === "dev-ready" || action === "health") {
|
||||
assertKnownOptions(args.slice(1), {}, `codex ${action}`);
|
||||
return codeQueueDevReady();
|
||||
@@ -5673,5 +6073,5 @@ export async function runCodeQueueCommand(config: UniDeskConfig, args: string[])
|
||||
const taskId = requireTaskId(taskIdArg, "codex steer");
|
||||
return codexSteerTask(taskId, args.slice(2));
|
||||
}
|
||||
throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel");
|
||||
throw new Error("codex command must be one of: prompt-lint, submit, enqueue, task, summary, show, tasks, overview, unread, terminal-unread, output, judge, read, mark-read, dev-ready, health, skills-sync, pr-preflight, runtime-preflight, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel");
|
||||
}
|
||||
|
||||
@@ -144,6 +144,63 @@ function issueEntryPlan(): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function prCloseoutPlan(): Record<string, unknown> {
|
||||
return {
|
||||
mutation: false,
|
||||
purpose: "PR closeout checklist for commander sessions and PR-bound GPT-5.5 runners; this plan is read-only and does not merge or close anything.",
|
||||
runnerBoundary: {
|
||||
mayCreateUpdateComment: true,
|
||||
maySelfCloseOrMergeOrdinaryPrWithinTaskBoundary: true,
|
||||
conditions: [
|
||||
"task explicitly authorizes PR closeout or merge/close",
|
||||
"PR is ordinary UniDesk source work, not production, release/v1, destructive rollback, secret, database, or runtime hot patch",
|
||||
"checks required by the task have passed or unrelated broad failures are documented",
|
||||
"base/head are reviewed and merge/close target matches the task boundary",
|
||||
],
|
||||
commanderRequiredWhen: [
|
||||
"conflicts or ambiguous ownership",
|
||||
"failed required checks without accepted explanation",
|
||||
"production/runtime/release/security/database scope",
|
||||
"user or commander explicitly reserves final action",
|
||||
],
|
||||
},
|
||||
readOnlyChecklist: [
|
||||
{
|
||||
actor: "runner-or-host",
|
||||
command: "bun scripts/cli.ts gh pr view <number> --repo pikasTech/unidesk --json body,title,state,stateDetail,head,base,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup",
|
||||
mutation: false,
|
||||
},
|
||||
{
|
||||
actor: "runner-or-host",
|
||||
command: "bun scripts/cli.ts gh pr files <number> --repo pikasTech/unidesk --limit 30",
|
||||
mutation: false,
|
||||
},
|
||||
{
|
||||
actor: "runner-or-host",
|
||||
command: "comment or hand off changed files, validation, residual risks, and read-only mergeability/status evidence",
|
||||
mutation: false,
|
||||
},
|
||||
],
|
||||
finalAction: {
|
||||
ordinaryRunnerAllowed: true,
|
||||
hostCommanderAllowedAfterReview: true,
|
||||
tools: [
|
||||
"system gh pr merge <number> --repo pikasTech/unidesk",
|
||||
"GitHub UI merge/close controls",
|
||||
"bun scripts/cli.ts gh pr close <number> --repo pikasTech/unidesk",
|
||||
],
|
||||
sourceMergeClosePolicy: "Use repo-owned, auditable GitHub paths; do not directly push target branches as a merge substitute.",
|
||||
},
|
||||
unideskCliBoundary: {
|
||||
mergeSupported: false,
|
||||
closeSupported: true,
|
||||
command: "bun scripts/cli.ts gh pr merge <number> --repo pikasTech/unidesk",
|
||||
degradedReason: "unsupported-command",
|
||||
automatedMergeImplemented: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function traceSummaryPlan(): Record<string, unknown> {
|
||||
return {
|
||||
mutation: false,
|
||||
@@ -203,6 +260,7 @@ function commanderPlan(args: string[]): Record<string, unknown> {
|
||||
promptGuidance: promptGuidancePlan(),
|
||||
traceSummary: traceSummaryPlan(),
|
||||
issueEntries: issueEntryPlan(),
|
||||
prCloseout: prCloseoutPlan(),
|
||||
claudeqqApproval: {
|
||||
mutation: false,
|
||||
commandShape: "bun scripts/cli.ts commander approval request --action <action> --dry-run",
|
||||
|
||||
+15
-1
@@ -5556,7 +5556,21 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
return prState(options.repo, token, number, sub === "close" ? "closed" : "open", false);
|
||||
}
|
||||
if (sub === "merge") {
|
||||
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
|
||||
return unsupportedCommand(
|
||||
"pr merge",
|
||||
options.repo,
|
||||
"PR merge is intentionally unsupported by the UniDesk REST CLI; PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks using repo-owned GitHub paths, while high-risk or ambiguous PRs stay commander-reviewed.",
|
||||
{
|
||||
closeoutBoundary: {
|
||||
runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close"],
|
||||
ordinaryRunnerFinalActionAllowed: true,
|
||||
commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
|
||||
hostAllowedToolsAfterReview: ["system gh pr merge", "GitHub UI merge/close"],
|
||||
unideskCliMergeSupported: false,
|
||||
degradedReason: "unsupported-command",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
if (sub !== "list" && !isPrReadCommand(sub)) {
|
||||
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, files, diff --stat, read/view, create, update/edit, close, reopen, comment create/delete, and unsupported merge/delete.");
|
||||
|
||||
+9
-1
@@ -58,6 +58,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "codex pr-preflight [--remote] [--push-dry-run --push-dry-run-ref refs/heads/probe/<name>] [--pr-create-dry-run --pr-create-dry-run-head <head>] [--issue N] [--full|--raw]", description: "Read-only PR admission check with compact commander output by default; use --full or --raw to expand the full runtime preflight, tool, and observation payload." },
|
||||
{ command: "codex task <taskId> [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch the bounded review view by default; --detail is still capped, while --full/trace/output explicitly expand evidence." },
|
||||
{ command: "codex tasks [--view supervisor|full] [--queue id] [--status status[,status]] [--unread|--unread-only] [--limit N] [--before-id id]", description: "Show the low-noise supervisor view by default: compact task rows, tiny local sections, activity counts, diagnostics, and drill-down commands; use --view full for detailed rows." },
|
||||
{ command: "codex unread [summary|mark-read] [--queue id] [--repo owner/name] [--issue N] [--status succeeded,failed,canceled] [--limit N] [--confirm]", description: "Summarize unread terminal backlog by repo, issue, status and queue without raw prompts; batch mark-read requires the explicit mark-read subcommand plus --confirm." },
|
||||
{ command: "codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]", description: "Fetch paged raw Code Queue output records; default caps large limits/text previews, --full-text explicitly expands one seq window." },
|
||||
{ command: "codex read <taskId>", description: "Mark one reviewed terminal task read and return terminal metadata plus final response; prompt/tool logs stay behind drill-down commands." },
|
||||
{ command: "codex dev-ready", description: "Fetch execution-container readiness, including sanitized skill injection status from /api/dev-ready." },
|
||||
@@ -246,7 +247,7 @@ function scheduleHelp(): unknown {
|
||||
|
||||
function codexHelp(): unknown {
|
||||
return {
|
||||
command: "codex deploy|prompt-lint|submit|task|tasks|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move",
|
||||
command: "codex deploy|prompt-lint|submit|task|tasks|unread|output|read|dev-ready|skills-sync|pr-preflight|judge|steer|interrupt|cancel|queues|queue|move",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts codex deploy <commitId> # disabled legacy deployment entry",
|
||||
@@ -256,6 +257,8 @@ function codexHelp(): unknown {
|
||||
"bun scripts/cli.ts codex submit --prompt-file /tmp/code-queue-prompt.md --queue <id> --dry-run",
|
||||
"bun scripts/cli.ts codex task <taskId> [--detail] [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]",
|
||||
"bun scripts/cli.ts codex tasks [--view supervisor|full] [--queue id] [--status succeeded,running] [--unread|--unread-only] [--limit N] [--before-id id]",
|
||||
"bun scripts/cli.ts codex unread [--repo owner/name] [--issue N] [--limit N]",
|
||||
"bun scripts/cli.ts codex unread mark-read [--repo owner/name] [--issue N] [--limit N] --confirm",
|
||||
"bun scripts/cli.ts codex output <taskId> [--tail|--from-start|--after-seq N|--before-seq N --limit N] [--full-text]",
|
||||
"bun scripts/cli.ts codex read <taskId>",
|
||||
"bun scripts/cli.ts codex dev-ready",
|
||||
@@ -277,6 +280,11 @@ function codexHelp(): unknown {
|
||||
default: "codex read marks a terminal task read and returns terminal metadata, final response, last error/judge, counts, and drill-down commands.",
|
||||
disclosure: "Full prompt, tool logs, and feedback prompts are not printed by codex read; use codex task/detail/trace/output for progressive disclosure.",
|
||||
},
|
||||
unreadTriage: {
|
||||
default: "codex unread is read-only by default and returns counts plus bounded task ids grouped by repo, issue, status and queue.",
|
||||
mutationGuard: "Batch mark-read is blocked unless the explicit mark-read subcommand is used with --confirm; use codex read <taskId> for per-task review.",
|
||||
disclosure: "Raw prompt, final response, trace and output are omitted; use the returned task/detail/trace/output/read commands for drill-down.",
|
||||
},
|
||||
examples: {
|
||||
promptLint: "bun scripts/cli.ts codex prompt-lint --prompt-file /tmp/code-queue-prompt.md",
|
||||
stdin: [
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceHealthResponse, summarizeMicroserviceObservation, summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import { parseNetworkPerfOptions, runNetworkPerf } from "./network-perf";
|
||||
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
|
||||
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync } from "./code-queue";
|
||||
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexPrPreflightQueryAsync, codexQueuesQueryAsync, codexTaskQueryAsync, codexTasksQueryAsync, codexUnreadTriageAsync } from "./code-queue";
|
||||
import { runDecisionCenterCommandAsync } from "./decision-center";
|
||||
import {
|
||||
artifactRegistryReadonlyResultFromCommand,
|
||||
@@ -781,8 +781,8 @@ function dispatchedTaskShape(remoteCommandShape: string): string {
|
||||
|
||||
async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise<unknown> {
|
||||
const action = args[1] ?? "task";
|
||||
if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "queues" && action !== "queue-list" && action !== "output" && action !== "judge" && action !== "pr-preflight" && action !== "runtime-preflight") {
|
||||
throw new Error("remote codex command must be: codex task <taskId>, codex tasks, codex queues, codex output <taskId>, codex judge <taskId> --attempt N, or codex pr-preflight [--remote]");
|
||||
if (action !== "task" && action !== "summary" && action !== "show" && action !== "tasks" && action !== "overview" && action !== "unread" && action !== "terminal-unread" && action !== "queues" && action !== "queue-list" && action !== "output" && action !== "judge" && action !== "pr-preflight" && action !== "runtime-preflight") {
|
||||
throw new Error("remote codex command must be: codex task <taskId>, codex tasks, codex unread, codex queues, codex output <taskId>, codex judge <taskId> --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)) {
|
||||
@@ -802,6 +802,8 @@ async function remoteCodeQueue(session: FrontendSession, args: string[]): Promis
|
||||
transport: "frontend",
|
||||
result: action === "tasks" || action === "overview"
|
||||
? await codexTasksQueryAsync(args.slice(1), fetcher)
|
||||
: action === "unread" || action === "terminal-unread"
|
||||
? await codexUnreadTriageAsync(args.slice(2), fetcher)
|
||||
: action === "queues" || action === "queue-list"
|
||||
? await codexQueuesQueryAsync(args.slice(2), fetcher)
|
||||
: action === "output"
|
||||
@@ -876,7 +878,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
||||
emitRemoteJson(name, {
|
||||
transport: "frontend",
|
||||
baseUrl: session.baseUrl,
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex queues", "codex judge <taskId> --attempt N", "codex pr-preflight [--remote]", "network perf"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "artifact-registry status|health", "ci publish-user-service --dry-run", "ci publish-backend-core --dry-run", "microservice list", "microservice status <id>", "microservice health <id>", "microservice diagnostics <id>", "microservice tunnel-self-test <id>", "microservice proxy <id> <path>", "decision upload <markdown-file>", "decision list", "decision show <id>", "codex task <taskId>", "codex tasks", "codex unread", "codex queues", "codex judge <taskId> --attempt N", "codex pr-preflight [--remote]", "network perf"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface CommanderContract {
|
||||
controlMicroservice: string;
|
||||
codeQueue: string;
|
||||
claudeqq: string;
|
||||
githubPrCloseout: string;
|
||||
};
|
||||
requiredCapabilities: string[];
|
||||
apiContract: {
|
||||
@@ -94,6 +95,7 @@ export function commanderContract(): CommanderContract {
|
||||
controlMicroservice: "Local skeleton for health, state, trace summary, and approval drafting with no live bridge or executor.",
|
||||
codeQueue: "Execution plane remains separate and is never restarted or attached by this skeleton.",
|
||||
claudeqq: "Approval draft destination only; no messages are sent from this stage.",
|
||||
githubPrCloseout: "PR-bound GPT-5.5 runners may self-close/merge ordinary in-boundary PRs after checks; commander review remains required for high-risk, ambiguous, failed-check, production, release, security, database, or runtime scopes.",
|
||||
},
|
||||
requiredCapabilities: [
|
||||
"host-codex-process-discovery",
|
||||
@@ -105,6 +107,7 @@ export function commanderContract(): CommanderContract {
|
||||
"trace-summary-plan",
|
||||
"issue-20-board-read-write-entry",
|
||||
"issue-46-brief-read-write-entry",
|
||||
"pr-closeout-boundary-plan",
|
||||
"claudeqq-high-risk-approval-entry",
|
||||
],
|
||||
apiContract: {
|
||||
|
||||
Reference in New Issue
Block a user