feat: add codex steer cli
This commit is contained in:
@@ -49,6 +49,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue <id>]`:通过 backend-core 私有代理提交 Code Queue 任务;控制面默认走主 server `code-queue-mgr` 写入 PostgreSQL,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。
|
||||
- `bun scripts/cli.ts codex task <taskId>`:按 Code Queue 任务 ID 查询初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,便于新任务引用历史 session。
|
||||
- `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]`:通过 Code Queue 私有代理向运行中的 active turn 注入纠偏提示,正式替代底层 `microservice proxy ... /steer` 调用。
|
||||
- `bun scripts/cli.ts codex interrupt|cancel <taskId>`:通过 Code Queue 私有代理中断运行任务或取消 queued/retry_wait 任务,规则见 `docs/reference/cli.md`。
|
||||
- `bun scripts/cli.ts server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。
|
||||
- `bun scripts/cli.ts job list [--limit N]` / `bun scripts/cli.ts job status latest [--tail-bytes N]`:分页查询 `.state/jobs/` 中的异步任务状态,状态输出只读日志尾部并保留完整日志路径,job 机制见 `docs/reference/cli.md`。
|
||||
|
||||
@@ -103,9 +103,9 @@
|
||||
|
||||
## T23 D601 Code Queue User Service
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `code-queue-mgr` 显示为 `providerId=main-server`、`deployment.mode=internal-sidecar`、Compose 后端 `http://code-queue-mgr:4278`、`frontend.integrated=false`,并确认稳定 `code-queue` 条目说明队列管理/提交/历史/轻量 Trace 默认由主 server `code-queue-mgr` 负责,D601 k3s Code Queue 只负责 scheduler/runner/active run control 和执行态写回;使用 `bun scripts/cli.ts server rebuild code-queue-mgr` 重建主 server 控制面,再运行 `bun scripts/cli.ts microservice health code-queue-mgr`、`bun scripts/cli.ts microservice health code-queue`、`bun scripts/cli.ts microservice proxy code-queue '/api/tasks/overview?limit=5&transcriptLimit=1&compact=1&afterSeq=0&preferId='`、`bun scripts/cli.ts codex submit --dry-run --queue <queueId> <prompt>` 和 `bun scripts/cli.ts codex task <已有taskId>`,确认普通控制/读取路径经 backend-core 分流到 master `code-queue-mgr`,返回 `role=master-control-plane`、`schemaReady=true`、PostgreSQL pool 上限、`noRunnerDependencies=true`、任务初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,不依赖 D601 `code-queue-write` ready endpoint。随后运行 `bun scripts/cli.ts codex deploy <已push的commitId>`,确认命令返回结构化错误并明确说明维护通道直连 D601 部署已禁用,且不会返回异步部署 job id;再运行 `bun scripts/cli.ts deploy apply --service code-queue --dry-run --run-now` 可只做 would-deploy 预览,去掉 `--dry-run` 时必须在运行时变更前拒绝 D601 直连部署。确认主 server 根目录 `docker-compose.yml` 中只存在 `code-queue-mgr` 而不存在执行面 `code-queue` service,并通过 `bun scripts/cli.ts ssh D601 argv bash -lc 'systemctl is-active k3s && KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o wide && sudo ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F docker.io/rancher/mirrored-pause:3.6 && ! docker ps --format "{{.Names}} {{.Image}}" | grep -E "[[:space:]]rancher/k3s:" && ! docker ps --format "{{.Names}}" | grep -Fx code-queue-backend'` 或等价检查证明 D601 k3s 是 WSL 原生 systemd 服务、native containerd 已有正确 pause sandbox 镜像、没有 active `rancher/k3s` 控制面容器且旧 direct Docker `code-queue-backend` 没有并行运行。运行 `bun scripts/cli.ts microservice proxy k3sctl-adapter /api/control-plane --raw` 和执行面专属 `bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw`,确认 D601 scheduler/read/write ready endpoint、`queue.storage.primary=postgres`、`queue.storage.postgresReady=true`、`queue.devReady.missingTools=[]`、`queue.devReady.docker.versionOk=true`、`queue.devReady.docker.composeOk=true`;`queue.devReady.ssh.ready` 只在需要跨 Provider SSH/Windows-native 任务时作为强制项。在 D601 active Code Queue Pod 内验证主 PostgreSQL 端口映射可执行 `select 1`,主 OA Event Flow 端口映射 `/health` 可访问,集群内 ClaudeQQ Service `http://claudeqq.unidesk.svc.cluster.local:3290/health` 可访问;这些映射不得成为任意公网入口。
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `code-queue-mgr` 显示为 `providerId=main-server`、`deployment.mode=internal-sidecar`、Compose 后端 `http://code-queue-mgr:4278`、`frontend.integrated=false`,并确认稳定 `code-queue` 条目说明队列管理/提交/历史/轻量 Trace 默认由主 server `code-queue-mgr` 负责,D601 k3s Code Queue 只负责 scheduler/runner/active run control 和执行态写回;使用 `bun scripts/cli.ts server rebuild code-queue-mgr` 重建主 server 控制面,再运行 `bun scripts/cli.ts microservice health code-queue-mgr`、`bun scripts/cli.ts microservice health code-queue`、`bun scripts/cli.ts microservice proxy code-queue '/api/tasks/overview?limit=5&transcriptLimit=1&compact=1&afterSeq=0&preferId='`、`bun scripts/cli.ts codex submit --dry-run --queue <queueId> <prompt>`、`bun scripts/cli.ts codex steer <runningTaskId> --prompt-stdin --dry-run` 和 `bun scripts/cli.ts codex task <已有taskId>`,确认普通控制/读取路径经 backend-core 分流到 master `code-queue-mgr`,返回 `role=master-control-plane`、`schemaReady=true`、PostgreSQL pool 上限、`noRunnerDependencies=true`、任务初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时;`codex steer --dry-run` 必须返回 `path=/api/tasks/<runningTaskId>/steer`、`method=POST`、prompt 字符数和 `truncated=false`,且不触碰运行中 session。随后运行 `bun scripts/cli.ts codex deploy <已push的commitId>`,确认命令返回结构化错误并明确说明维护通道直连 D601 部署已禁用,且不会返回异步部署 job id;再运行 `bun scripts/cli.ts deploy apply --service code-queue --dry-run --run-now` 可只做 would-deploy 预览,去掉 `--dry-run` 时必须在运行时变更前拒绝 D601 直连部署。确认主 server 根目录 `docker-compose.yml` 中只存在 `code-queue-mgr` 而不存在执行面 `code-queue` service,并通过 `bun scripts/cli.ts ssh D601 argv bash -lc 'systemctl is-active k3s && KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o wide && sudo ctr --address /run/k3s/containerd/containerd.sock -n k8s.io images ls | grep -F docker.io/rancher/mirrored-pause:3.6 && ! docker ps --format "{{.Names}} {{.Image}}" | grep -E "[[:space:]]rancher/k3s:" && ! docker ps --format "{{.Names}}" | grep -Fx code-queue-backend'` 或等价检查证明 D601 k3s 是 WSL 原生 systemd 服务、native containerd 已有正确 pause sandbox 镜像、没有 active `rancher/k3s` 控制面容器且旧 direct Docker `code-queue-backend` 没有并行运行。运行 `bun scripts/cli.ts microservice proxy k3sctl-adapter /api/control-plane --raw` 和执行面专属 `bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw`,确认 D601 scheduler/read/write ready endpoint、`queue.storage.primary=postgres`、`queue.storage.postgresReady=true`、`queue.devReady.missingTools=[]`、`queue.devReady.docker.versionOk=true`、`queue.devReady.docker.composeOk=true`;`queue.devReady.ssh.ready` 只在需要跨 Provider SSH/Windows-native 任务时作为强制项。在 D601 active Code Queue Pod 内验证主 PostgreSQL 端口映射可执行 `select 1`,主 OA Event Flow 端口映射 `/health` 可访问,集群内 ClaudeQQ Service `http://claudeqq.unidesk.svc.cluster.local:3290/health` 可访问;这些映射不得成为任意公网入口。
|
||||
|
||||
随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Code Queue`,确认页面显示默认模型 `gpt-5.5`、默认执行 Provider `D601`、默认工作目录 `/workspace`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务 ID、复制任务 ID、引用按钮、任务耗时、引用任务 ID、清空输入、创建成功提示、任务提交表单、Trace 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。批量验收时设置 `入队份数=5` 或用 `---` 分隔 5 段 prompt,一次性入队 5 条任务,确认 5 条任务按顺序运行并全部进入 succeeded 或可解释的非成功终态,不能只运行第一条后停止;其中任一任务被 judge 判定 `fail` 时只能把当前任务标为 failed,后续 queued 任务仍必须继续推进。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。MiniMax judge 必须能处理 Markdown fence/夹杂文本等 JSON 去噪;若去噪后仍失败,必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 修复后重试,日志中应出现 `judge_json_parse_retry`,且 repair 成功时仍以 `source=minimax` 返回。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 D601 env-file 运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。
|
||||
随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Code Queue`,确认页面显示默认模型 `gpt-5.5`、默认执行 Provider `D601`、默认工作目录 `/workspace`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务 ID、复制任务 ID、引用按钮、任务耗时、引用任务 ID、清空输入、创建成功提示、任务提交表单、Trace 输出、attempt 表、MiniMax/fallback judge 状态、追加 prompt、打断和重试控件;通过页面提交一个小任务,确认任务进入 queued/running/succeeded 或可解释的 failed 状态,并且输出区能看到运行中的 Codex 消息。需要验证运行中纠偏时,提交一个明确会保持 running 的长任务,使用 `printf '<纠偏提示>' | bun scripts/cli.ts codex steer <runningTaskId> --prompt-stdin` 注入 prompt,确认命令返回有界 task/queue 摘要和 `codex task`/`codex output` 后续命令,且 `bun scripts/cli.ts codex task <runningTaskId> --trace --tail --limit 80` 能看到 `Steer prompt` 或 `turn/steer`;正式验收不得再使用底层 `microservice proxy code-queue /api/tasks/<taskId>/steer` 作为主入口。批量验收时设置 `入队份数=5` 或用 `---` 分隔 5 段 prompt,一次性入队 5 条任务,确认 5 条任务按顺序运行并全部进入 succeeded 或可解释的非成功终态,不能只运行第一条后停止;其中任一任务被 judge 判定 `fail` 时只能把当前任务标为 failed,后续 queued 任务仍必须继续推进。测试异常中断时可以提交长任务后点击 `打断`,确认任务变为 canceled 或被 judge 标记为非成功终态;自动重试只应在服务端/传输异常、任务正常结束但 execution record 显示未完成、或 judge 判定 retry 时发生;retry 必须复用已有 Codex thread 并 append 继续执行 prompt,只有当前任务 complete 后才推进队列中的下一个任务。MiniMax judge 必须能处理 Markdown fence/夹杂文本等 JSON 去噪;若去噪后仍失败,必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 修复后重试,日志中应出现 `judge_json_parse_retry`,且 repair 成功时仍以 `source=minimax` 返回。Codex provider key 只能通过 `OPENAI_API_KEY`、`CRS_OAI_KEY` 这类运行时环境透传,MiniMax API key 只能通过 D601 env-file 运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。
|
||||
|
||||
## T23A D601 k3s CI Gate
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
|
||||
- `codex read <taskId>` 在人工审阅后标记单个终态任务已读;列表、overview 和 supervisor 视图只返回这个命令字段,不得自动执行,也不得批量清空未读状态。
|
||||
- `codex dev-ready` 查询 Code Queue `/api/dev-ready` 并返回有界 readiness 摘要,包括工具、Docker、Codex config、SSH 和 `devReady.skills`。`devReady.skills` 只暴露 `UNIDESK_SKILLS_PATH`、是否存在、是否只读、skillCount、`cli-spec` 是否可见和修复建议,不输出宿主 auth/token 文件内容。
|
||||
- `codex judge <taskId> --attempt N [--dry-run] [--include-prompt]` 通过 Code Queue 私有代理按指定 attempt 单步复现 judge;这是执行面诊断入口,仍依赖 D601 scheduler/runner 侧的真实 judge builder、MiniMax 调用路径和执行环境。默认会真实调用 MiniMax,`--dry-run` 只返回 prompt/payload 大小、attempt 窗口和重建来源诊断,`--include-prompt` 仅用于本地深度排查。
|
||||
- `codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run]` 通过 Code Queue 私有代理向正在运行的 task 注入纠偏提示,正式替代底层 `microservice proxy code-queue /api/tasks/<taskId>/steer` 调用。prompt 必须且只能来自位置参数、文件或 stdin 之一;`--dry-run` 只输出将要发送的结构化请求和 prompt 字符数,不触碰运行中 session。真实执行仍走 `/api/microservices/code-queue/proxy/api/tasks/<taskId>/steer`,只能作用于 D601 scheduler 上存在 active steerable turn 的 running task;若任务不在 running/active-turn 状态,返回上游 409 和有界 task 摘要,不得静默成功。
|
||||
- `codex interrupt|cancel <taskId>` 通过 Code Queue 私有代理请求中断;running/judging 任务会请求 D601 当前 agent run 停止,queued/retry_wait 任务的取消也必须保持与 WebUI 相同代理路径,返回有界 task 摘要和后续查询命令。任何需要接触 active run 的动作仍属于 D601 执行面。
|
||||
- Code Queue 多队列 lane 由 `codex` 命令命名空间管理:`queues [--full|--all]` 列表、`queue create <queueId>` 创建、`queue merge <sourceQueueId> --into <targetQueueId>` 合并、`move <taskId> --queue <queueId>` 迁移;这些队列管理入口默认由主 server `code-queue-mgr` 直管 PostgreSQL,仍通过稳定 `code-queue` 用户服务代理路径访问。`codex queues` 默认只返回 active/nonempty/unread/runnable queue 摘要、全局 counts 和 execution diagnostics;完整队列数组必须显式 `--full` 或 `--all`。同一个 queue 内部串行执行,不同 queue 之间并行执行。迁移只允许尚未被 scheduler claim 的 `queued`/`retry_wait` 任务,必须满足 `startedAt=null`、`currentAttempt=0` 且没有 active thread/turn;已进入 `running`/`judging` 或已有 claim 标记的任务返回 409,不得被 move/merge 回写成 queued。合并会移动可迁移任务归属并自动删除源 queue 记录,只保留合并后的目标 queue;若 source 或 target queue 存在 active/claimed 任务,合并整体返回 409。合并后的目标 queue 按任务原 `queueEnteredAt`/`createdAt` 时间顺序串行,成功迁移 queued/retry_wait 任务后由 D601 scheduler 轮询推进。
|
||||
- 所有 `codex` 查询和管理命令必须走与 WebUI 相同的 backend-core 私有代理路径 `/api/microservices/code-queue/proxy/...`;CLI 不得为了提交、移动、中断、取消或队列管理直接调用 D601 内部 Service、数据库、pod curl 或 k3sctl scheduler 子服务。若该路径失败,应先修复 CLI/backend/provider tunnel 链路,而不是绕过控制面。
|
||||
@@ -137,7 +138,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`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/months/show/edit/upsert`、`codex task <taskId>`、`codex tasks`、`codex output <taskId>`、`codex judge <taskId> --attempt N` 和 `ssh <PROVIDER_ID> <remote-command>`。其中 `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`。若确实需要旧行为,可使用 `--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`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/months/show/edit/upsert`、`codex task <taskId>`、`codex tasks`、`codex output <taskId>`、`codex judge <taskId> --attempt N` 和 `ssh <PROVIDER_ID> <remote-command>`。运行中纠偏 `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`。若确实需要旧行为,可使用 `--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 自测,不能视为交付完成。
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { writeFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; json: JsonRecord | null } {
|
||||
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
|
||||
cwd: process.cwd(),
|
||||
input: stdin,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const stdout = String(result.stdout || "");
|
||||
let json: JsonRecord | null = null;
|
||||
try {
|
||||
json = JSON.parse(stdout) as JsonRecord;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
stdout,
|
||||
stderr: String(result.stderr || ""),
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
function nestedRecord(value: unknown, path: string[]): JsonRecord {
|
||||
let current: unknown = value;
|
||||
for (const key of path) {
|
||||
assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected object while traversing JSON", { path, key, current });
|
||||
current = (current as JsonRecord)[key];
|
||||
}
|
||||
assertCondition(current !== null && typeof current === "object" && !Array.isArray(current), "expected nested object", { path, current });
|
||||
return current as JsonRecord;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
||||
}
|
||||
|
||||
function assertDryRunPrompt(response: JsonRecord, expectedText: string): void {
|
||||
assertCondition(response.ok === true, "CLI dry-run should succeed", response);
|
||||
const data = nestedRecord(response.data, []);
|
||||
assertCondition(data.dryRun === true, "dry-run response should expose dryRun=true", data);
|
||||
const prompt = nestedRecord(response.data, ["request", "body", "prompt"]);
|
||||
assertCondition(prompt.text === expectedText, "dry-run prompt text mismatch", prompt);
|
||||
assertCondition(prompt.chars === expectedText.length, "dry-run prompt char count mismatch", prompt);
|
||||
assertCondition(prompt.truncated === false, "dry-run prompt must not truncate", prompt);
|
||||
}
|
||||
|
||||
export function runCodeQueueCliSteerContract(): JsonRecord {
|
||||
const positional = runCli(["codex", "steer", "codex_test_task", "correct the running task", "--dry-run"]);
|
||||
assertDryRunPrompt(positional.json ?? {}, "correct the running task");
|
||||
|
||||
const stdin = runCli(["codex", "steer", "codex_test_task", "--prompt-stdin", "--dry-run"], "stdin steer prompt\n");
|
||||
assertDryRunPrompt(stdin.json ?? {}, "stdin steer prompt\n");
|
||||
|
||||
const promptFile = join(tmpdir(), `unidesk-code-queue-steer-${process.pid}.txt`);
|
||||
writeFileSync(promptFile, "file steer prompt", "utf8");
|
||||
try {
|
||||
const fromFile = runCli(["codex", "steer", "codex_test_task", "--prompt-file", promptFile, "--dry-run"]);
|
||||
assertDryRunPrompt(fromFile.json ?? {}, "file steer prompt");
|
||||
} finally {
|
||||
unlinkSync(promptFile);
|
||||
}
|
||||
|
||||
const duplicateSource = runCli(["codex", "steer", "codex_test_task", "positional", "--prompt-stdin", "--dry-run"], "stdin\n");
|
||||
assertCondition(duplicateSource.status !== 0, "duplicate prompt source should fail", duplicateSource.json ?? { stdout: duplicateSource.stdout });
|
||||
const duplicateMessage = String(nestedRecord(duplicateSource.json, ["error"]).message || "");
|
||||
assertCondition(duplicateMessage.includes("exactly one prompt source"), "duplicate prompt source error should be explicit", { duplicateMessage });
|
||||
|
||||
const unknownOption = runCli(["codex", "steer", "codex_test_task", "--queue", "default", "prompt", "--dry-run"]);
|
||||
assertCondition(unknownOption.status !== 0, "unknown steer option should fail", unknownOption.json ?? { stdout: unknownOption.stdout });
|
||||
const unknownMessage = String(nestedRecord(unknownOption.json, ["error"]).message || "");
|
||||
assertCondition(unknownMessage.includes("unsupported codex steer option: --queue"), "unknown option error should name option", { unknownMessage });
|
||||
|
||||
const help = runCli(["codex", "help"]);
|
||||
assertCondition(help.status === 0 && help.json?.ok === true, "codex help should succeed", help.json ?? { stdout: help.stdout });
|
||||
const usage = stringArray(nestedRecord(help.json?.data, []).usage);
|
||||
assertCondition(usage.some((line) => line.includes("codex steer <taskId>")), "codex help should list steer", { usage });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
checks: [
|
||||
"steer positional dry-run",
|
||||
"steer stdin dry-run",
|
||||
"steer prompt-file dry-run",
|
||||
"duplicate prompt source failure",
|
||||
"unsupported option failure",
|
||||
"codex help lists steer",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const result = runCodeQueueCliSteerContract();
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
+90
-23
@@ -53,6 +53,11 @@ interface CodexSubmitOptions {
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
interface CodexSteerOptions {
|
||||
prompt: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
interface CompactTaskMutationResponseOptions {
|
||||
fullPrompt?: boolean;
|
||||
}
|
||||
@@ -83,6 +88,7 @@ interface CodexTasksEntry {
|
||||
show: string;
|
||||
trace: string;
|
||||
output: string;
|
||||
steer: string;
|
||||
read: string;
|
||||
full: string;
|
||||
};
|
||||
@@ -966,6 +972,7 @@ function taskWatchEntry(task: Record<string, unknown>, summary: Record<string, u
|
||||
? summary.traceHint
|
||||
: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`;
|
||||
const outputCommand = `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`;
|
||||
const steerCommand = `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>`;
|
||||
return {
|
||||
taskId,
|
||||
queueId: asString(task.queueId) || null,
|
||||
@@ -983,6 +990,7 @@ function taskWatchEntry(task: Record<string, unknown>, summary: Record<string, u
|
||||
show: typeof summaryCommands?.show === "string" && summaryCommands.show.length > 0 ? summaryCommands.show : showCommand,
|
||||
trace: typeof summaryCommands?.trace === "string" && summaryCommands.trace.length > 0 ? summaryCommands.trace : traceCommand,
|
||||
output: outputCommand,
|
||||
steer: steerCommand,
|
||||
read: `bun scripts/cli.ts codex read ${taskId}`,
|
||||
full: `bun scripts/cli.ts codex task ${taskId} --full`,
|
||||
},
|
||||
@@ -1527,38 +1535,45 @@ function codexMoveTask(taskId: string, queueId: string): unknown {
|
||||
return unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/move`), { method: "POST", body: { queueId } }));
|
||||
}
|
||||
|
||||
function promptFromSubmitArgs(args: string[]): string {
|
||||
function promptFromArgs(args: string[], command: string, valueOptions: Set<string>): string {
|
||||
const promptFile = optionValue(args, ["--prompt-file", "--file"]);
|
||||
const promptStdin = hasFlag(args, "--prompt-stdin") || hasFlag(args, "--stdin");
|
||||
const promptArgs = positionalArgsWithValueOptions(args, new Set([
|
||||
"--prompt-file",
|
||||
"--file",
|
||||
"--queue",
|
||||
"--queue-id",
|
||||
"--provider",
|
||||
"--provider-id",
|
||||
"--cwd",
|
||||
"--workdir",
|
||||
"--model",
|
||||
"--reasoning-effort",
|
||||
"--execution-mode",
|
||||
"--mode",
|
||||
"--max-attempts",
|
||||
"--reference-task-id",
|
||||
"--reference",
|
||||
"--ref",
|
||||
]));
|
||||
const promptArgs = positionalArgsWithValueOptions(args, valueOptions);
|
||||
const sources = [promptFile !== undefined, promptStdin, promptArgs.length > 0].filter(Boolean).length;
|
||||
if (sources !== 1) throw new Error("codex submit requires exactly one prompt source: positional prompt, --prompt-file, or --prompt-stdin");
|
||||
if (sources !== 1) throw new Error(`${command} requires exactly one prompt source: positional prompt, --prompt-file, or --prompt-stdin`);
|
||||
const text = promptFile !== undefined
|
||||
? (promptFile === "-" ? readFileSync(0, "utf8") : readFileSync(promptFile, "utf8"))
|
||||
: promptStdin
|
||||
? readFileSync(0, "utf8")
|
||||
: promptArgs.join(" ");
|
||||
if (text.trim().length === 0) throw new Error("codex submit prompt must not be empty");
|
||||
if (text.trim().length === 0) throw new Error(`${command} prompt must not be empty`);
|
||||
return text;
|
||||
}
|
||||
|
||||
const submitPromptValueOptions = new Set([
|
||||
"--prompt-file",
|
||||
"--file",
|
||||
"--queue",
|
||||
"--queue-id",
|
||||
"--provider",
|
||||
"--provider-id",
|
||||
"--cwd",
|
||||
"--workdir",
|
||||
"--model",
|
||||
"--reasoning-effort",
|
||||
"--execution-mode",
|
||||
"--mode",
|
||||
"--max-attempts",
|
||||
"--reference-task-id",
|
||||
"--reference",
|
||||
"--ref",
|
||||
]);
|
||||
|
||||
const steerPromptValueOptions = new Set([
|
||||
"--prompt-file",
|
||||
"--file",
|
||||
]);
|
||||
|
||||
function referenceTaskIdsFromOptions(args: string[]): string[] {
|
||||
const values = optionValues(args, ["--reference-task-id", "--reference", "--ref"]);
|
||||
const ids: string[] = [];
|
||||
@@ -1595,7 +1610,7 @@ function parseSubmitOptions(args: string[]): CodexSubmitOptions {
|
||||
? positiveIntegerOption(args, ["--max-attempts"], 99, 99)
|
||||
: undefined;
|
||||
return {
|
||||
prompt: promptFromSubmitArgs(args),
|
||||
prompt: promptFromArgs(args, "codex submit", submitPromptValueOptions),
|
||||
queueId: optionValue(args, ["--queue", "--queue-id"]),
|
||||
providerId: optionValue(args, ["--provider-id", "--provider"]),
|
||||
cwd: optionValue(args, ["--cwd", "--workdir"]),
|
||||
@@ -1608,6 +1623,17 @@ function parseSubmitOptions(args: string[]): CodexSubmitOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function parseSteerOptions(args: string[]): CodexSteerOptions {
|
||||
assertKnownOptions(args, {
|
||||
flags: ["--prompt-stdin", "--stdin", "--dry-run"],
|
||||
valueOptions: ["--prompt-file", "--file"],
|
||||
}, "codex steer");
|
||||
return {
|
||||
prompt: promptFromArgs(args, "codex steer", steerPromptValueOptions),
|
||||
dryRun: hasFlag(args, "--dry-run"),
|
||||
};
|
||||
}
|
||||
|
||||
function submitPayload(options: CodexSubmitOptions): Record<string, unknown> {
|
||||
return {
|
||||
prompt: options.prompt,
|
||||
@@ -1649,6 +1675,7 @@ function compactTaskMutationResponse(task: unknown, options: CompactTaskMutation
|
||||
show: `bun scripts/cli.ts codex task ${taskId}`,
|
||||
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
|
||||
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
|
||||
steer: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>`,
|
||||
interrupt: `bun scripts/cli.ts codex interrupt ${taskId}`,
|
||||
move: `bun scripts/cli.ts codex move ${taskId} --queue <queueId>`,
|
||||
},
|
||||
@@ -1752,6 +1779,42 @@ function codexInterruptTask(taskId: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function codexSteerTask(taskId: string, args: string[]): unknown {
|
||||
const options = parseSteerOptions(args);
|
||||
const prompt = textView(options.prompt, true, 3000);
|
||||
const request = {
|
||||
path: `/api/tasks/${taskId}/steer`,
|
||||
method: "POST",
|
||||
body: { prompt },
|
||||
};
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
request,
|
||||
commands: {
|
||||
run: `bun scripts/cli.ts codex steer ${taskId} --prompt-file <path>`,
|
||||
rawProxy: `bun scripts/cli.ts microservice proxy code-queue /api/tasks/${encodeURIComponent(taskId)}/steer --method POST --body-json '{"prompt":"..."}'`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const response = unwrapCodexResponse(coreInternalFetch(codeQueueProxyPath(`/api/tasks/${encodeURIComponent(taskId)}/steer`), { method: "POST", body: { prompt: options.prompt } }));
|
||||
return {
|
||||
upstream: response.upstream,
|
||||
steer: {
|
||||
accepted: true,
|
||||
prompt,
|
||||
},
|
||||
task: compactTaskMutationResponse(response.body.task),
|
||||
queue: compactQueueMutationSummary(response.body.queue),
|
||||
commands: {
|
||||
show: `bun scripts/cli.ts codex task ${taskId}`,
|
||||
trace: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${defaultTraceLimit}`,
|
||||
output: `bun scripts/cli.ts codex output ${taskId} --tail --limit ${defaultOutputLimit}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
|
||||
const [action = "task", taskIdArg] = args;
|
||||
if (action === "submit" || action === "enqueue") {
|
||||
@@ -1798,5 +1861,9 @@ export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]
|
||||
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
|
||||
return codexInterruptTask(taskId);
|
||||
}
|
||||
throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, queues, queue list, queue create, queue merge, move, interrupt, cancel");
|
||||
if (action === "steer") {
|
||||
const taskId = requireTaskId(taskIdArg, "codex steer");
|
||||
return codexSteerTask(taskId, args.slice(2));
|
||||
}
|
||||
throw new Error("codex command must be one of: submit, enqueue, task, summary, show, tasks, overview, output, judge, read, mark-read, dev-ready, health, queues, queue list, queue create, queue merge, move, steer, interrupt, cancel");
|
||||
}
|
||||
|
||||
+3
-1
@@ -55,6 +55,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "codex read <taskId>", description: "Mark one reviewed terminal task read; never run automatically as part of listing." },
|
||||
{ command: "codex dev-ready", description: "Fetch execution-container readiness, including sanitized skill injection status from /api/dev-ready." },
|
||||
{ command: "codex judge <taskId> --attempt N [--dry-run] [--include-prompt]", description: "Replay one stored Code Queue attempt through the same judge context builder and MiniMax judge call path used by the live queue worker." },
|
||||
{ command: "codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run]", description: "Push a bounded corrective prompt into a running Code Queue task through the stable private proxy path." },
|
||||
{ command: "codex interrupt|cancel <taskId>", description: "Request interrupt for a running Code Queue task, or cancel a queued/retry_wait task, through the same private proxy." },
|
||||
{ command: "codex (queues [--full|--all] | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>)", description: "List low-noise queue summaries by default; full queue rows require --full/--all." },
|
||||
{ command: "job list [--limit N] [--include-command]", description: "List async jobs from .state/jobs with a bounded default page." },
|
||||
@@ -194,7 +195,7 @@ function scheduleHelp(): unknown {
|
||||
|
||||
function codexHelp(): unknown {
|
||||
return {
|
||||
command: "codex deploy|submit|task|tasks|output|read|dev-ready|judge|interrupt|cancel|queues|queue|move",
|
||||
command: "codex deploy|submit|task|tasks|output|read|dev-ready|judge|steer|interrupt|cancel|queues|queue|move",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts codex deploy <commitId> # disabled legacy deployment entry",
|
||||
@@ -205,6 +206,7 @@ function codexHelp(): unknown {
|
||||
"bun scripts/cli.ts codex read <taskId>",
|
||||
"bun scripts/cli.ts codex dev-ready",
|
||||
"bun scripts/cli.ts codex judge <taskId> --attempt N [--dry-run] [--include-prompt]",
|
||||
"bun scripts/cli.ts codex steer <taskId> [prompt|--prompt-file path|--prompt-stdin] [--dry-run]",
|
||||
"bun scripts/cli.ts codex interrupt|cancel <taskId>",
|
||||
"bun scripts/cli.ts codex queues [--full|--all] | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user