fix: stabilize code queue runtime and trace flow
This commit is contained in:
@@ -23,6 +23,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
|
||||
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch`、`glob` 与 `skill-discover`;`apply-patch`、`py`、`skills`、结构化 `find`、`glob` 和 `argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md` 和 `docs/reference/provider-gateway.md`。
|
||||
- `bun scripts/cli.ts microservice list/status/health/proxy`:管理和验证挂载在主 server 或计算节点 Docker 中的用户服务,OA Event Flow/Code Queue/Todo Note/Baidu Netdisk on main-server 与 FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.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 server stop`:以异步 job 停止固定 Compose 项目中的全部 UniDesk 服务,停止后用 `server status` 复核。
|
||||
- `bun scripts/cli.ts job list` / `bun scripts/cli.ts job status latest`:查询 `.state/jobs/` 中的异步任务状态,job 机制见 `docs/reference/cli.md`。
|
||||
- `bun scripts/cli.ts debug health` / `bun scripts/cli.ts debug dispatch` / `bun scripts/cli.ts debug task`:通过 Docker 内网 core、真实 HTTP、WebSocket、系统指标、Docker 状态和 Host SSH 维护桥流程调试健康检查、任务下发与任务结果,调试规则见 `docs/reference/cli.md`。
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
## T23 Main Server Code Queue User Service
|
||||
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `code-queue` 显示为 `providerId=main-server`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、`code-queue:4222` 后端映射和 `code-queue-backend` 容器摘要;运行 `bun scripts/cli.ts server rebuild code-queue` 并用 `bun scripts/cli.ts job status <jobId>` 等待该 job 成功且输出 post-up validation,再运行 `bun scripts/cli.ts microservice health code-queue`、`bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw`、`bun scripts/cli.ts microservice proxy code-queue /api/tasks` 和 `bun scripts/cli.ts codex task <已有taskId>`,确认链路通过 backend-core、main-server provider-gateway 和 Code Queue 后端,且 task id 查询返回初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,且 `queue.devReady.ok=true`、`devReady.missingTools=[]`、`docker.versionOk=true`、`docker.composeOk=true`,必需工具包含 `docker`、`docker-compose`、`jq`、`ssh`、`rsync`、`pip3` 和 `unzip`;提交会产生较多命令输出的小任务后,`/health` 和 `/api/tasks` 仍必须在常规 CLI 超时内返回,容器内不得堆积无超时 healthcheck 进程。运行 `bun scripts/cli.ts microservice proxy code-queue /api/dev-containers/D601/start --method POST --raw`(或等价同源 POST),确认 Code Queue 拉起 D601 `unidesk-codex-dev-D601` 开发容器,返回 JSON 中 `masterProxy.mode=ssh-tun-nat`、`verification.pingGoogleOk=true`、`directPingEvidence` 表明建隧道前直连失败、`pingGoogleLog` 包含 `PING google.com` 和 `0% packet loss`,且 `masterProxyEvidenceAfter` 中 `UNIDESK-CODEX-DEV-D601` NAT 链或 `tun601` 计数比 ping 前增长,证明开发容器网络经 master server 全局代理而不是 D601 本地出网。再用 `bun scripts/cli.ts microservice proxy code-queue /api/tasks --raw` 确认返回的 `queue.executionProviders` 至少包含 `main-server` 和 `D601`,`main-server` 的 `defaultWorkdir=/root/unidesk`,`D601` 的 `defaultWorkdir=/home/ubuntu`;通过 API 提交任务时 `providerId=main-server` 必须在本机容器执行,`providerId=D601` 必须自动复用/拉起 D601 开发容器并在任务 JSON、Trace summary 和日志中显示 `providerId=D601`、`cwd=/home/ubuntu`。随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Code Queue`,确认页面显示默认模型 `gpt-5.5`、默认执行 Provider `main-server`、默认工作目录 `/root/unidesk`、Provider 下拉菜单包含 `D601 · /home/ubuntu`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务 ID、复制任务 ID、引用按钮、任务耗时、引用任务 ID、清空输入、创建成功提示、任务提交表单、Codex CLI-like 输出、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 只能通过 `UNIDESK_CODE_QUEUE_MINIMAX_API_KEY` 这类运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。
|
||||
阅读 `AGENTS.md`(本项目 `AGENTS.md` 同时承担 `SKILL.md` 对 `scripts/cli.ts` 的解释职责),然后用 cli 手动测试以下内容:运行 `bun scripts/cli.ts microservice list`,确认 `code-queue` 显示为 `providerId=main-server`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、`code-queue:4222` 后端映射和 `code-queue-backend` 容器摘要;运行 `bun scripts/cli.ts server rebuild code-queue` 并用 `bun scripts/cli.ts job status <jobId>` 等待该 job 成功且输出 post-up validation,再运行 `bun scripts/cli.ts microservice health code-queue`、`bun scripts/cli.ts microservice proxy code-queue /api/dev-ready --raw`、`bun scripts/cli.ts microservice proxy code-queue /api/tasks` 和 `bun scripts/cli.ts codex task <已有taskId>`,确认链路通过 backend-core、main-server provider-gateway 和 Code Queue 后端,且 task id 查询返回初始 prompt、最后 assistant message、工具调用摘要、attempt/judge/error 和耗时,且 `queue.devReady.ok=true`、`devReady.missingTools=[]`、`docker.versionOk=true`、`docker.composeOk=true`,必需工具包含 `docker`、`docker-compose`、`jq`、`ssh`、`rsync`、`pip3` 和 `unzip`;提交会产生较多命令输出的小任务后,`/health` 和 `/api/tasks` 仍必须在常规 CLI 超时内返回,容器内不得堆积无超时 healthcheck 进程。运行 `bun scripts/cli.ts microservice proxy code-queue /api/dev-containers/D601/start --method POST --raw`(或等价同源 POST),确认 Code Queue 拉起 D601 `unidesk-codex-dev-D601` 开发容器,返回 JSON 中 `masterProxy.mode=ssh-tun-nat`、`verification.pingGoogleOk=true`、`directPingEvidence` 表明建隧道前直连失败、`pingGoogleLog` 包含 `PING google.com` 和 `0% packet loss`,且 `masterProxyEvidenceAfter` 中 `UNIDESK-CODEX-DEV-D601` NAT 链或 `tun601` 计数比 ping 前增长,证明开发容器网络经 master server 全局代理而不是 D601 本地出网。再用 `bun scripts/cli.ts microservice proxy code-queue /api/tasks --raw` 确认返回的 `queue.executionProviders` 至少包含 `main-server` 和 `D601`,`main-server` 的 `defaultWorkdir=/root/unidesk`,`D601` 的 `defaultWorkdir=/home/ubuntu`,并且 `queue.executionModes` 包含 `windows-native`;通过 API 提交任务时 `providerId=main-server` 必须在本机容器执行,`providerId=D601` 必须自动复用/拉起 D601 开发容器并在任务 JSON、Trace summary 和日志中显示 `providerId=D601`、`cwd=/home/ubuntu`;通过 API 或前端提交 `providerId=D601`、`executionMode=windows-native`、`model=gpt-5.4-mini`、`cwd=/mnt/f/Work/ConStart` 的小任务,必须显示 `executionMode=windows-native`,且 Trace 中的 Codex initialize/userAgent 证明 Codex 运行在 Windows 原生环境。随后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Code Queue`,确认页面显示默认模型 `gpt-5.5`、默认执行 Provider `main-server`、默认工作目录 `/root/unidesk`、Provider 下拉菜单包含 `D601 · /home/ubuntu`、模型下拉菜单包含 `gpt-5.4-mini`/`gpt-5.4`/`gpt-5.5`、入队份数、队列指标、任务 ID、复制任务 ID、引用按钮、任务耗时、引用任务 ID、清空输入、创建成功提示、任务提交表单、Codex CLI-like 输出、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 只能通过 `UNIDESK_CODE_QUEUE_MINIMAX_API_KEY` 这类运行时环境传入,禁止写入 `config.json`、Dockerfile、源码或测试文档。
|
||||
|
||||
## T24 MET Nonlinear D601 GPU User Service
|
||||
|
||||
|
||||
+9
-4
@@ -113,8 +113,8 @@ services:
|
||||
dockerfile: src/components/microservices/code-queue/Dockerfile
|
||||
container_name: code-queue-backend
|
||||
restart: unless-stopped
|
||||
mem_limit: 600m
|
||||
memswap_limit: 1536m
|
||||
mem_limit: "${UNIDESK_CODE_QUEUE_MEM_LIMIT:-1200m}"
|
||||
memswap_limit: "${UNIDESK_CODE_QUEUE_MEMSWAP_LIMIT:-1800m}"
|
||||
depends_on:
|
||||
- database
|
||||
- backend-core
|
||||
@@ -138,7 +138,7 @@ services:
|
||||
OA_EVENT_FLOW_BASE_URL: "http://oa-event-flow:4255"
|
||||
CODE_QUEUE_MAX_ATTEMPTS: "99"
|
||||
CODE_QUEUE_MAX_ACTIVE_QUEUES: "${UNIDESK_CODE_QUEUE_MAX_ACTIVE_QUEUES:-0}"
|
||||
NODE_OPTIONS: "${UNIDESK_CODE_QUEUE_NODE_OPTIONS:---max-old-space-size=768}"
|
||||
NODE_OPTIONS: "${UNIDESK_CODE_QUEUE_NODE_OPTIONS:---max-old-space-size=512}"
|
||||
CODE_QUEUE_IN_MEMORY_OUTPUT_RECORDS: "${UNIDESK_CODE_QUEUE_IN_MEMORY_OUTPUT_RECORDS:-10}"
|
||||
CODE_QUEUE_IN_MEMORY_EVENT_RECORDS: "${UNIDESK_CODE_QUEUE_IN_MEMORY_EVENT_RECORDS:-10}"
|
||||
CODE_QUEUE_CODEX_SQLITE_LOG_EXPORT_BATCH_SIZE: "${UNIDESK_CODE_QUEUE_CODEX_SQLITE_LOG_EXPORT_BATCH_SIZE:-500}"
|
||||
@@ -149,6 +149,11 @@ services:
|
||||
CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID: "${UNIDESK_CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID:-D601}"
|
||||
CODE_QUEUE_DEV_CONTAINER_IMAGE: "${UNIDESK_CODE_QUEUE_DEV_CONTAINER_IMAGE:-}"
|
||||
CODE_QUEUE_DEV_CONTAINER_WORKDIR: "${UNIDESK_CODE_QUEUE_DEV_CONTAINER_WORKDIR:-/home/ubuntu}"
|
||||
CODE_QUEUE_WINDOWS_NATIVE_CODEX_DEFAULT_WORKDIR: "${UNIDESK_CODE_QUEUE_WINDOWS_NATIVE_CODEX_DEFAULT_WORKDIR:-/mnt/f/Work/ConStart}"
|
||||
CODE_QUEUE_WINDOWS_NATIVE_CODEX_BRIDGE_DIR: "${UNIDESK_CODE_QUEUE_WINDOWS_NATIVE_CODEX_BRIDGE_DIR:-/home/ubuntu/.unidesk/code-queue/windows-native-codex}"
|
||||
CODE_QUEUE_WINDOWS_NATIVE_CODEX_COMMAND: "${UNIDESK_CODE_QUEUE_WINDOWS_NATIVE_CODEX_COMMAND:-codex app-server --listen stdio://}"
|
||||
CODE_QUEUE_WINDOWS_NATIVE_CODEX_CONNECT_HOST: "${UNIDESK_CODE_QUEUE_WINDOWS_NATIVE_CODEX_CONNECT_HOST:-host.docker.internal}"
|
||||
CODE_QUEUE_WINDOWS_NATIVE_CODEX_IDLE_TIMEOUT_MS: "${UNIDESK_CODE_QUEUE_WINDOWS_NATIVE_CODEX_IDLE_TIMEOUT_MS:-600000}"
|
||||
CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: "${UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED:-true}"
|
||||
CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL: "${UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL:-http://backend-core:8080/api/microservices/claudeqq/proxy}"
|
||||
CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE: "${UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE:-private}"
|
||||
@@ -162,7 +167,7 @@ services:
|
||||
MINIMAX_API_KEY: "${UNIDESK_CODE_QUEUE_MINIMAX_API_KEY:-}"
|
||||
MINIMAX_API_BASE: "${UNIDESK_CODE_QUEUE_MINIMAX_API_BASE:-https://api.minimaxi.com/v1}"
|
||||
MINIMAX_MODEL: "${UNIDESK_CODE_QUEUE_MINIMAX_MODEL:-MiniMax-M2.7}"
|
||||
MINIMAX_JUDGE_TIMEOUT_MS: "${UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS:-60000}"
|
||||
MINIMAX_JUDGE_TIMEOUT_MS: "${UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS:-90000}"
|
||||
MINIMAX_JUDGE_REPAIR_ATTEMPTS: "${UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_REPAIR_ATTEMPTS:-2}"
|
||||
LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_DAY}/${UNIDESK_LOG_PREFIX}_code-queue.jsonl"
|
||||
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-1GiB}"
|
||||
|
||||
@@ -22,6 +22,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
|
||||
- `codex task <taskId>` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。
|
||||
- `codex task <taskId> --trace --tail|--from-start|--after-seq N|--before-seq N --limit N` 按页拉取 Code Queue 的逻辑 trace;响应会返回 `nextAfterSeq`、`previousBeforeSeq`、`hasMore`、`hasBefore` 和下一页/上一页命令,默认 `--trace` 取最新一页,需要完整 prompt/最后 response 时加 `--full`。
|
||||
- `codex output <taskId> --tail|--from-start|--after-seq N|--before-seq N --limit N [--full-text]` 按原始 output seq 分页读取底层记录;当 trace 行提示 `commandOmittedLines`、`bodyOmittedLines` 或 `rawSeqs` 时,用该命令按 seq 补取完整信息,默认仍有单条文本预览上限,显式 `--full-text` 才返回该页全文。
|
||||
- `codex judge <taskId> --attempt N [--dry-run] [--include-prompt]` 通过 Code Queue 私有代理按指定 attempt 单步复现 judge;后端会从 PostgreSQL task JSON 与 output 归档重建该 attempt 在真实队列 worker 中的 `QueueTask`/`CodexRunResult`,再调用同一套 judge prompt builder 和 MiniMax 请求路径。默认会真实调用 MiniMax,`--dry-run` 只返回 prompt/payload 大小、attempt 窗口和重建来源诊断,`--include-prompt` 仅用于本地深度排查。
|
||||
- Code Queue 多队列 lane 由 `codex` 命令命名空间管理:`queues` 列表、`queue create <queueId>` 创建、`queue merge <sourceQueueId> --into <targetQueueId>` 合并、`move <taskId> --queue <queueId>` 迁移;同一个 queue 内部串行执行,不同 queue 之间并行执行。合并会移动任务归属并自动删除源 queue 记录,只保留合并后的目标 queue;合并后的目标 queue 按任务原 `queueEnteredAt`/`createdAt` 时间顺序串行。迁移 queued/retry_wait 任务后会立即调度目标 queue。
|
||||
- `job list` 与 `job status` 查询 `.state/jobs/` 文件系统状态,是异步命令的可观测入口。
|
||||
- `debug health`、`debug dispatch` 与 `debug task` 走真实内部 core、WebSocket、数据库、provider、系统指标、Docker 状态和 Host SSH 维护桥流程,只用于开发调试,不写入 `TEST.md` 的正式验收步骤。
|
||||
@@ -105,7 +106,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/proxy`、`codex task <taskId>` 和 `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/proxy`、`codex task <taskId>`、`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>`。
|
||||
|
||||
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 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 自测,不能视为交付完成。
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ frontend shell 必须把左侧主模块与顶部子标签编译为统一的 URL
|
||||
- `ClaudeQQ` 子标签必须把 D601 ClaudeQQ 后端渲染为 UniDesk React 控件,包括 NapCat 容器登录二维码、NapCat HTTP/WS 状态、事件缓存、QQ 事件订阅表、订阅创建表单、消息推送表单、主用户私聊账号 `645275593` 标记、最近 QQ 事件、已发送记录和显式原始 JSON 按钮。
|
||||
- `Baidu Netdisk` 子标签必须把主 server `baidu-netdisk-backend` 后端渲染为 UniDesk React 控件,包括 OAuth 设备码二维码/用户码登录、账号容量、配置工作根文件浏览(当前默认百度网盘根目录 `/`)、staging 目录上传/下载任务、上传/下载自测按钮与 MD5 结果、脱敏安全说明、日志摘要和显式原始 JSON 按钮;不得把 access token、refresh token、dlink 或 staging 文件字节流裸露到浏览器。
|
||||
- `OA Event Flow` 子标签必须把主 server `oa-event-flow-backend` 后端渲染为 UniDesk React 控件,包括服务健康、事件表、tag 过滤、SSE live 状态、Trace/STEP stats 表、Code Queue/Pipeline 标签入口和显式原始 JSON 按钮;默认页面不得裸铺完整事件 JSON,事件表只展示结构化摘要,完整 envelope/payload 只能通过 `查看原始JSON` 打开。
|
||||
- `Code Queue` 子标签必须把主 server `code-queue-backend` 后端渲染为 UniDesk React 控件,包括多 queue lane、queue 内串行、queue 间并行、queue 合并(点击“合并 queue”后必须用公共 `UniDeskDialog` 打开独立小窗口,用下拉菜单选择源 queue;不得把源 queue 选择控件塞进正常提交任务的 Queue 选择区;合并后自动删除源 queue,只保留合并后的目标 queue,目标 queue 按原 queueEnteredAt/createdAt 时间顺序串行)、任务 ID/复制任务 ID、引用按钮、任务耗时、任务提交/批量提交、引用任务 ID、创建成功提示、清空输入、模型下拉、显式入队份数、默认模型 `gpt-5.5`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮;Codex CLI-like 输出流必须始终保留任务的初始 `Submitted prompt` 和运行中 `Steer prompt`;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call;Code Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;Trace 首屏可以是摘要预览,但终态任务被选中后必须自动在后台加载完整 Trace,手动“加载完整 Trace”也必须从 Code Queue output archive 分页补齐早期 trace,不得把 preview 的 `hasMore=false` 当成完整历史;即使热状态为控制体积裁剪了早期 raw output,也要从结构化 `basePrompt/displayPrompt/promptHistory` 和 archive 合成完整用户输入与 agent trace,并且初始 prompt 默认显示注入前 prompt 而不是引用注入全文;当初始 prompt 含引用注入时,引用内容必须默认折叠,并只在 Trace 的初始消息中提供可展开的“最终传入 Codex 的真实完整 prompt”,不得再渲染独立 Prompt 全量卡片;多轮引用注入必须按上游/最早上下文在前、直接引用在后的顺序排列,每一轮必须有明确 `Reference Round N/M` 分割线和时间范围,不能用固定 6 轮截断引用链;点击队列引用按钮必须自动把该任务 ID 写入提交表单的引用输入框,引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。
|
||||
- `Code Queue` 子标签必须把主 server `code-queue-backend` 后端渲染为 UniDesk React 控件,包括多 queue lane、queue 内串行、queue 间并行、queue 合并(点击“合并 queue”后必须用公共 `UniDeskDialog` 打开独立小窗口,用下拉菜单选择源 queue;不得把源 queue 选择控件塞进正常提交任务的 Queue 选择区;合并后自动删除源 queue,只保留合并后的目标 queue,目标 queue 按原 queueEnteredAt/createdAt 时间顺序串行)、任务 ID/复制任务 ID、引用按钮、任务耗时、任务提交/批量提交、引用任务 ID、创建成功提示、清空输入、模型下拉、执行 Provider 下拉、执行模式下拉(默认容器/本机或 `windows-native`)、显式入队份数、默认模型 `gpt-5.5`、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、运行中追加 prompt、打断、手动重试和显式原始 JSON 按钮;`windows-native` 模式必须在任务 JSON、卡片和 Trace 头部显示,并要求非主 server WSL Provider 与 `/mnt/<drive>` 工作目录;Codex CLI-like 输出流必须始终保留任务的初始 `Submitted prompt` 和运行中 `Steer prompt`;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call,但非错误 system event 默认只保留在原始输出/数据库中,不在 TraceView 展示;Code Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;Trace 首屏可以是摘要预览,但终态任务被选中后必须自动在后台加载完整 Trace,手动“加载完整 Trace”也必须从 Code Queue output archive 分页补齐早期 trace,不得把 preview 的 `hasMore=false` 当成完整历史;即使热状态为控制体积裁剪了早期 raw output,也要从结构化 `basePrompt/displayPrompt/promptHistory` 和 archive 合成完整用户输入与 agent trace,并且初始 prompt 默认显示注入前 prompt 而不是引用注入全文;当初始 prompt 含引用注入时,引用内容必须默认折叠,并只在 Trace 的初始消息中提供可展开的“最终传入 Codex 的真实完整 prompt”,不得再渲染独立 Prompt 全量卡片;多轮引用注入必须按上游/最早上下文在前、直接引用在后的顺序排列,每一轮必须有明确 `Reference Round N/M` 分割线和时间范围,不能用固定 6 轮截断引用链;点击队列引用按钮必须自动把该任务 ID 写入提交表单的引用输入框,引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示;连续执行同一 prompt 应通过入队份数一次性生成多条任务,避免快速连点造成操作员误判。
|
||||
- `Code Queue` 前端改进必须在同一任务内重建并上线公网 frontend,不能只修改源码或本地 bundle;重建 frontend 是无状态 WebUI 替换,不会导致 Code Queue 长期任务失败。已结束未读任务只能在 task card 边角显示类似未读消息的 `codex-unread-badge` 圆点和“标为已读”操作,不得把整张卡片改成红色/琥珀色失败态边框、背景或胶囊标签;状态栏的“结束未读”提示也不得使用失败态红色。
|
||||
- `Code Queue` 前端必须把 PostgreSQL-backed backend API 作为 task、queue、readAt/未读状态和 attempt 状态的唯一数据来源;不得用 `localStorage`、`sessionStorage` 或 IndexedDB 持久化这些业务状态,也不得在后端标记已读失败时伪造本地成功。前端允许保留 React 内存态、请求 in-flight guard 和本轮页面缓存,但刷新页面或切换设备后的状态必须完全由后端 PostgreSQL 数据恢复。
|
||||
- `Code Queue` 前后端通信必须采用渐进式披露:首屏只请求 queue/task 轻量摘要、必要的 selected preview 和小体积统计,不得默认拉取完整 transcript、raw output、原始 JSON 或全部历史任务;加载下一页或搜索分页时必须显式传递 `selected=0`、`includeActive=0`、`stats=0` 等价开关,避免每一页重复请求 selected/active/stats;点击/选中 task 后再按需加载 summary、prompt part、trace step、raw output 或完整 Trace。`read`/`mark all read` 应调用专用 mutation 并用后端返回的 patch 更新当前内存态,不能为了隐藏未读圆点而强制刷新完整 overview;请求仍需遵守 PostgreSQL 权威源,失败时不得本地伪造已读。Code Queue 性能问题应优先通过缩小 API 响应、分页/cursor、去重 in-flight 请求、短 TTL 且 mutation 失效的页面缓存和后端 SQL 聚合解决,避免以重写渲染层或把大 JSON 藏在 DOM/React state 中规避慢请求。
|
||||
|
||||
@@ -138,22 +138,22 @@ Baidu Netdisk 在 UniDesk 语境中按纯后端服务管理:不得暴露百度
|
||||
- OpenCode 远程执行:`minimax-m2.7` 走 OpenCode JSON event port 时,本地和远程命令都必须显式执行 `opencode run ...`;远程 Docker exec 不得退化成 `exec run ...`,否则会在目标容器内变成 `bash: exec: run: not found`。OpenCode JSON stream 的终态判定以“当前进程退出码 + 当前 attempt 的最终 assistant response”为准:`exit=0` 且当前 attempt 产生非空最终回复时,即使上游没有发 `step_finish` 事件,也应视为正常 terminal;非零退出、无当前最终回复或传输关闭才进入 retry。每个 attempt 的 `finalResponse` 必须只来自当前 OpenCode/Codex turn,禁止在当前 turn 未产出最终回复时回退复用 task 上一次 `finalResponse`,否则会把旧任务内容误判为本轮完成。
|
||||
- Codex 控制:服务内部启动 `codex app-server --listen stdio://`,用 JSON-RPC 调用 `thread/start`、`turn/start`、`turn/steer` 和 `turn/interrupt`,并监听 `turn/completed`、assistant delta、reasoning delta、command output delta、file diff delta 等通知生成前端可轮询的 transcript。
|
||||
- 用户输入持久化:任务初始 prompt 以 `basePrompt/displayPrompt` 作为结构化来源,运行中追加的 `turn/steer` prompt 必须写入 `promptHistory`;transcript 构建时从这些结构化字段合成 `Submitted prompt` 和 `Steer prompt`,不能只依赖有 600 条上限的 raw output,否则长任务输出增长后会丢失关键人工指令。
|
||||
- 队列语义:`POST /api/tasks` 或 `/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks` 与 `GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`GET /api/tasks/{id}/summary` 返回按任务 ID 查询的结构化摘要,包括初始 prompt、最后 assistant message、工具调用摘要、attempt、judge、错误和耗时;CLI 入口是 `bun scripts/cli.ts codex task <taskId>`。`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt;`POST /api/tasks/{id}/interrupt` 或 `DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。队列 worker 必须隔离单个 task 的异常,不能因为某个 app-server、judge 异常或 judge 判定 `fail` 让后续 queued 任务停止;`fail` 只把当前任务标为 failed,随后必须继续扫描并推进下一个 queued/retry_wait 任务。当存在 queued/retry_wait 且 worker 空闲时,watchdog 必须自动重新调度。
|
||||
- 队列语义:`POST /api/tasks` 或 `/api/tasks/batch` 入队,服务始终只运行一个 Codex turn;当前任务真正终止后才推进下一个任务。`GET /api/tasks` 与 `GET /api/tasks/{id}` 返回队列、attempt、judge 和输出;`GET /api/tasks/{id}/summary` 返回按任务 ID 查询的结构化摘要,包括初始 prompt、最后 assistant message、工具调用摘要、attempt、judge、错误和耗时;CLI 入口是 `bun scripts/cli.ts codex task <taskId>`。`GET|POST /api/tasks/{id}/judge?attempt=N` 与 CLI `bun scripts/cli.ts codex judge <taskId> --attempt N` 用于单步复现指定 attempt 的 judge,必须复用真实队列 worker 的上下文构建、prompt 压缩、MiniMax 调用、JSON 去噪/repair 和 fallback 路径;`dryRun=1`/`--dry-run` 只输出 prompt/payload 和重建诊断,不调用 MiniMax。`POST /api/tasks/{id}/steer` 向运行中 turn 推入 prompt;`POST /api/tasks/{id}/interrupt` 或 `DELETE /api/tasks/{id}` 打断/取消;`POST /api/tasks/{id}/retry` 手动重试。队列 worker 必须隔离单个 task 的异常,不能因为某个 app-server、judge 异常或 judge 判定 `fail` 让后续 queued 任务停止;`fail` 只把当前任务标为 failed,随后必须继续扫描并推进下一个 queued/retry_wait 任务。当存在 queued/retry_wait 且 worker 空闲时,watchdog 必须自动重新调度。
|
||||
- 稳定性与重启恢复:Code Queue 的第一目标是长期稳定可用;部署修复或运维排障时不得因为担心容器重启会打断任务而拒绝重启、重建或替换 `code-queue-backend`。容器重启、服务进程重启和镜像替换后,队列、`promptHistory`、running/judging/retry_wait 任务和 active session 元数据必须从 PostgreSQL 恢复,并在已有 `codexThreadId` 可用时用 `thread/resume` 和 continuation prompt 无缝继续当前任务;如果原 app-server turn 已丢失,也必须把当前任务恢复到可 retry/continue 的状态,不能错误推进下一个任务或永久卡住。主 server 侧重建必须走 `server rebuild code-queue`,该 job 受 `.state/locks/server-compose.lock` 串行化约束,并且必须在 build 后执行 no-deps force-recreate 与 post-up health validation;禁止在 job 中先手工 `docker rm` 再依赖后续命令补救,因为中断窗口会让容器消失并触发 frontend `direct microservice proxy failed`。重启后出现 active task 丢失、手动 steer/interrupt 记录丢失、running 任务卡死、误判完成、跳过当前任务、容器消失或阻塞队列,均属于 Code Queue 的 P0 核心缺陷,必须先修复并补充 restart-recovery 验收,不能把“避免重启”作为交付策略。
|
||||
- 调度与 active run slot:Code Queue 必须把“queue processor 正在等待/退避/轮询”和“实际占用 Codex/OpenCode 子进程运行槽”分开建模;`CODE_QUEUE_MAX_ACTIVE_QUEUES` 只限制真实 active run slot,不能把 retry backoff、等待内存下降或等待前序任务的 `processingQueues` 计入 active slot,否则设置全局 active slot 上限时,一个空等队列会把其他 runnable queue 永久饿死。多个 queue 同时等待 active slot 时必须显式维护 FIFO waiter 队列,避免某个长 retry/backoff 队列刚释放 slot 就立刻重抢,导致更早进入等待的 `retry_wait` 任务长期饥饿;`/health` 必须同时暴露真实 `activeQueueIds`、`activeRunSlotCount`、等待中的 `processingQueueIds` 和 active slot waiters,排障时以 active run slot 与 waiter 顺序判断是否真的有任务在跑、谁应下一个启动。restart-recovery 后的 `retry_wait` 任务若缺失 `codexThreadId`/OpenCode session id,不得无限拒绝 retry;必须用紧凑 recovery prompt 和原始任务摘要重新开一个 agent thread/session,让任务继续推进并在 Trace 中留下 recovery 证据。任何修改 scheduler、retry backoff、queue move、manual retry、shutdown recovery 或内存等待逻辑时,都必须保留“空等 processor 不占 active run slot”、“等待者 FIFO 不饥饿”和“缺失 thread/session 可恢复”的自测或 live 验证。
|
||||
- 内存优化过程与防回归:主 server 内存预算很小,Code Queue 的内存治理必须按“PostgreSQL 权威源优先、进程热状态最小化、容器硬上限兜底”的顺序设计。长期可复用的优化路径是:先确认任务、queue、readAt、promptHistory、active session 和通知 outbox 均可从 PostgreSQL 恢复;再把历史任务列表、详情、统计、Trace/output 和 `/health` 的只读查询改为 PostgreSQL 直读或聚合查询;随后只把 `queued`、`running`、`judging`、`retry_wait` 等调度必需任务载入 Bun 堆,并在 PostgreSQL 查询侧裁剪 hot `output`/`events`;最后用 dirty-only flush、append-only 输出归档、Codex SQLite 小批量导出、`bun --smol`、`mem_limit=600m`、`memswap_limit=1536m`、`NODE_OPTIONS=--max-old-space-size=768` 和 cgroup memory watchdog 作为运行时防线。PostgreSQL 到进程的单次读取足够快,不能为了减少 SQL 查询把全部历史 `task_json`、Trace、output 或统计摘要常驻内存;任何新增缓存都必须有默认较小的环境变量上限、明确淘汰策略、可从 PostgreSQL 或 append-only 归档重建,且不得影响重启恢复。新增或修改 `/api/tasks`、overview、stats、summary、transcript、output、trace、health、flush、scheduler 和通知路径时,禁止在常规请求中调用会物化全量历史任务 JSON 的代码,禁止启动后无条件重写全量历史 task JSON,禁止用未设上限的 `Map`/数组保存历史 output/event/Trace,`CODE_QUEUE_MAX_ACTIVE_QUEUES=0` 表示不按 queue 数量设置全局排队上限;如显式设置为正数,必须同时说明内存预算并补充内存压测验收。memory watchdog 必须以 cgroup working set 为主要判断,且在 swap 仍有余量时不得提前杀掉唯一 active run;否则 TypeScript/Playwright 这类短时高内存验证会被错误中断并让 retry 队列反复震荡。
|
||||
- 列表/详情延迟优化原则:Code Queue 控制面交互的长期目标是常规历史规模下首屏、`GET /api/tasks/overview`、`POST /api/tasks/<id>/read` 和分页加载均在 1s 内完成;性能面板出现十几秒级 `code_queue_direct_proxy` 或 `core_proxy` 慢操作时,必须优先按后端查询形态和前后端通信策略定位,不能把问题归因于 React 渲染后只改 UI。后端优化顺序是:先为 queue、status、updated/created 时间、readAt/terminal unread 和常用筛选条件补齐 PostgreSQL 索引;再用 SQL `COUNT`、`GROUP BY`、条件聚合和分页 ID 查询生成 queue/status/stats/unread 摘要;随后按 ID 轻量加载当前页、selected、active 和 unread priority task,禁止为了列表或已读操作解析完整 Trace、output archive、Codex transcript 或物化全量历史 `task_json`。`read`/`read-all` 这类 mutation 必须是 SQL-only 更新并返回最小 patch/queue 计数,不能触发 overview 全量重算或重载所有任务;启动 warm 只能预热小体积聚合和索引路径,不得把历史任务作为常驻缓存。允许 frontend/backend 代理使用秒级、严格有界、mutation 自动失效的 overview micro-cache 来吸收重复刷新,但 cache 只能作为抖动保护,不能替代数据库索引、聚合查询和分页披露,也不能让 stale readAt/queue/status 状态跨设备可见。
|
||||
- Trace/实时输出热路径防回归:Code Queue 的 `appendOutput`、output archive append、`publishTaskEvent`、SSE `/api/events`、任务列表、overview、task meta 和 `/health` 都属于热路径,必须保持 O(1) 或明确小常数上界;这些路径不得同步调用完整 transcript 构建器、`taskFullOutput`、output archive 全量读取、Codex session/log 文件解析、完整 `task_json` 物化或任何会随历史输出长度增长的统计。输出追加时必须增量维护轻量持久化指标,至少包括 `stepCount`、`llmStepCount`、`outputMaxSeq` 或等价字段;列表、overview、meta、SSE 事件和 `/health` 只能读取这些指标或小体积 SQL 聚合。完整 Trace、`trace-summary`、`trace-steps`、`trace-step`、transcript/output 详情允许在显式详情请求中解析归档,但必须分页或有界、使用短 TTL 或容量受限缓存,并在 archive append 后失效。若 frontend 性能面板出现 Code Queue direct proxy 502、`/api/tasks/overview`/trace 接口成批超时,或容器内 `/health` 在 active output 持续追加时也卡住,优先按 Bun event-loop starvation/backpressure 排查,而不是先改 React 渲染;修复必须证明热路径不再随 output/archive 历史线性增长。
|
||||
- 完成判定:app-server `turn/completed` 的 `turn.status=completed|interrupted|failed` 只代表 Codex turn 已结束;即使 `completed` 也必须把原始任务、当前 attempt 的 assistant 最终回复、command/file-change 事件、stderr tail 和 current attempt events 组成 execution record 交给 judge 判断是否真的完成。MiniMax judge 之前和之后都必须保留少量协议级硬门禁:明确用户 interrupt 判为 fail;当前 attempt `terminalStatus=failed|null`、传输在终态前关闭、或当前 attempt 最终回复为空时判为 retry;这些门禁只保护“本轮 turn 是否可被验收”的事实,不得发明业务实现要求。协议门禁通过后,配置了 `UNIDESK_CODE_QUEUE_MINIMAX_API_KEY` 且 MiniMax 可用时,MiniMax `MiniMax-M2.7` 对业务是否 `complete|retry|fail` 的判定是权威结果;当且仅当 MiniMax LLM 调用失效(未配置、额度/限流/网络/超时不可用、JSON 去噪与 repair 全部耗尽、或返回超预算反馈且修复耗尽)时,才允许启用非 LLM/fallback 判断。MiniMax 返回必须先做 JSON 去噪,支持去除 Markdown fence、`json` 标签和从夹杂文本中提取平衡 JSON object;如果去噪后仍无法解析,服务必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 做 JSON repair 重试,重试次数由 `UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_REPAIR_ATTEMPTS` 控制,默认 `2`,耗尽后才进入 fallback,并在 fallback 原因中保留 MiniMax 失败信息。
|
||||
- 完成判定:app-server `turn/completed` 的 `turn.status=completed|interrupted|failed` 只代表 Codex turn 已结束;即使 `completed` 也必须把原始任务、当前 attempt 的 assistant 最终回复、command/file-change 事件、stderr tail 和 current attempt events 组成 execution record 交给 judge 判断是否真的完成。MiniMax judge 输入必须做有界压缩,保留终态、最终回复、关键错误/命令/部署证据和摘要计数,避免长 transcript 让 MiniMax 请求超时;默认 `UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS=90000`。MiniMax judge 之前和之后都必须保留少量协议级硬门禁:明确用户 interrupt 判为 fail;当前 attempt `terminalStatus=failed|null`、传输在终态前关闭、或当前 attempt 最终回复为空时判为 retry;这些门禁只保护“本轮 turn 是否可被验收”的事实,不得发明业务实现要求。协议门禁通过后,配置了 `UNIDESK_CODE_QUEUE_MINIMAX_API_KEY` 且 MiniMax 可用时,MiniMax `MiniMax-M2.7` 对业务是否 `complete|retry|fail` 的判定是权威结果;当且仅当 MiniMax LLM 调用失效(未配置、额度/限流/网络/超时不可用、JSON 去噪与 repair 全部耗尽、或返回超预算反馈且修复耗尽)时,才允许启用非 LLM/fallback 判断。MiniMax 返回必须先做 JSON 去噪,支持去除 Markdown fence、`json` 标签和从夹杂文本中提取平衡 JSON object;如果去噪后仍无法解析,服务必须把解析错误和上一轮去噪前原始回答反馈给 MiniMax 做 JSON repair 重试,重试次数由 `UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_REPAIR_ATTEMPTS` 控制,默认 `2`,耗尽后才进入 fallback,并在 fallback 原因、task JSON、attempt summary 和 TraceView 中保留 MiniMax 失败阶段、是否超时、耗时、prompt/payload 大小、HTTP 状态、错误名和响应预览。
|
||||
- Judge 权威边界:MiniMax 成功返回可解析、预算内的 judge JSON 后,Code Queue 不得用旧 attempt 的 429/exceeded retry limit 证据、历史 output 字符串、面向特定任务的正则或 `hardCompletionBlockers`/`retryRequiredReasons` 覆盖一次协议有效的完成判定;尤其不能因为 attempt 1 的限流中断仍在历史输出里,就禁止 MiniMax 把 attempt 2 的正常完成判为 `complete`。允许的本地 safety override 必须限定为协议事实和系统交付纪律:用户显式打断、当前 attempt 未正常终止、当前 attempt 没有最终 assistant response、最终回复停在并发文件确认而非交付、或 runtime/UI/service 变更承认未部署验证。所有 override 都必须写入 `_safetyOverride`、生成紧凑 continuation prompt,并由自测或 judge probe 覆盖;不得把业务猜测伪装成本地硬门禁。
|
||||
- Retry/推进语义:`retry` 不是新开一个独立任务或完全新 session;只要已有 `codexThreadId`,服务必须 `thread/resume` 原 thread 并 append 一个继续执行 prompt。continuation/judge feedback prompt 只应携带本轮缺口、恢复原因、验收要求和有界原始任务摘要,禁止重新注入完整引用上下文、历史 transcript 或长 JSON;服务重启恢复类 feedback 尤其必须保持短 prompt,依赖现有 thread 上文继续。超长 prompt 必须在 prompt 合成源头解决:每个 feedback/recovery/judge 生成器都要从结构化字段选择必要信息、去重合并缺口并提供按需查询入口,禁止先合成超长 prompt 再在末端用 substring/safePreview 一刀切硬截断;硬截断会静默丢失验收信息,风险高于长 prompt 本身。若 MiniMax `continuePrompt` 超出预算,必须要求 MiniMax 基于原始 judge 输入重新合成紧凑反馈,repair 耗尽后才可进入 fallback;不得把已生成的长 prompt 截尾后发送给 Codex。若 MiniMax 成功返回了预算内 `continuePrompt`,必须原样使用该反馈,不得再用 71-Freq、`period_sum/mpu_read_num`、`mpu_read_num`、历史限流中断等字符串识别把它覆盖成“简洁原始需求 continuation”。只有 judge 判定 `complete` 后,队列 worker 才把当前任务标为成功并推进下一个 queued/retry_wait 任务。非 LLM/fallback 判定产生的 `retry` 最多累计 `3` 次;达到上限后当前任务必须转为 `failed` 并记录原因,worker 继续推进后续 queued/retry_wait 任务,避免 fallback safety override 或硬编码判断造成无限循环。
|
||||
- Judge 探针:`GET|POST /api/judge/probe` 使用同一套 judge 逻辑跑内置 synthetic execution records,覆盖正常完成、正常结束但只给计划、未上线/未部署的服务或 WebUI 改动、传输中断和用户打断等样本,返回 `hits`、`total`、`hitRate`、每例 `expected` 与 `decision`;该接口不得回显 MiniMax API key。
|
||||
- Judge 探针与复现:`GET|POST /api/judge/probe` 使用同一套 judge 逻辑跑内置 synthetic execution records,覆盖正常完成、正常结束但只给计划、未上线/未部署的服务或 WebUI 改动、传输中断和用户打断等样本,返回 `hits`、`total`、`hitRate`、每例 `expected` 与 `decision`;该接口不得回显 MiniMax API key。真实任务排障必须优先使用 `codex judge <taskId> --attempt N` 或 `/api/tasks/{id}/judge?attempt=N`,响应要包含 attempt 窗口、promptChars/payloadBytes、stored judge 对比、MiniMax 失败阶段和是否因历史 per-attempt events 缺失而降级为 retained events 重建。
|
||||
- 模型选择:默认 Codex 模型是 `gpt-5.5`,内置模型队列包含 `gpt-5.5`、`gpt-5.4-mini`、`gpt-5.4`;`gpt-5.5` 的默认 reasoning effort 必须是 `xhigh`,可通过 `CODE_QUEUE_MODEL_REASONING_EFFORTS` 追加或覆盖模型级默认值;每个入队任务可通过前端模型下拉菜单或 API 覆盖 `model`、`cwd`、`reasoningEffort` 和 `maxAttempts`,`maxAttempts` 上限为 `99`。Judge 判定 `retry` 或非用户取消类 `fail` 时必须继续已有 `codexThreadId`,不能新建 session;重试间隔使用指数退避,从 `1s` 开始,最大 `10min`。MiniMax 不可用而进入 fallback/non-LLM 判定时,当前 attempt 的 429、Too Many Requests、exceeded retry limit、overloaded、stream disconnected 等服务/限流错误应判定为 `retry`,不能当作完成;MiniMax 可用时,这些内容只能作为当前 attempt 的 factual evidence 提供给 MiniMax,不能通过硬编码覆盖 MiniMax 结果。
|
||||
- 状态与日志:`main-server` 默认工作目录为容器内 `/root/unidesk`,该路径映射主 server 的 `~/unidesk`;同时保留 `/workspace` 映射以兼容历史任务。非主 server Provider 的任务默认工作目录为 `/home/ubuntu`,任务 JSON、列表、Trace 摘要和 CLI 查询都必须显示 `providerId` 与最终 `cwd`。Code Queue 的任务、queue、`readAt`/未读状态、attempt、judge、`promptHistory`、active session 元数据、控制状态和 ClaudeQQ 通知 outbox 一律以主 PostgreSQL 为权威,分别写入 `unidesk_code_queue_tasks`、`unidesk_code_queue_queues` 与 `unidesk_code_queue_notifications`;`DATABASE_URL` 是必需配置,服务不得在 PostgreSQL 缺失或不可用时进入文件存储模式。`.state/code-queue/state.json` 不再作为任务或 queue 状态存储,不得重新引入本地 JSON fallback;服务启动必须以 PostgreSQL 为唯一来源恢复队列,并把 running/judging 任务恢复为 retry_wait。主 server 内存很少,Code Queue 必须把“内存是稀缺资源”作为核心设计约束:历史任务列表、详情、统计和只读 Trace 查询优先从 PostgreSQL 直读,进程内只保留当前 running/judging、queued、retry_wait 等调度必需热任务,不得把全部历史 task JSON 长期缓存到 Bun 堆;需要短期热缓存时必须有严格上限、可裁剪、可从 PostgreSQL 和 append-only 输出归档重建。WebUI 不得用 browser `localStorage`、`sessionStorage` 或 IndexedDB 持久化 task/queue/readAt/unread 等业务状态;浏览器只能保留临时 UI 内存缓存,刷新后必须重新从后端读取 PostgreSQL 权威数据。Codex CLI-like output/Trace 的完整记录可以使用 append-only 文件作为日志型归档,但任务状态、未读状态和列表摘要不得依赖这些文件作为权威来源;`/api/tasks/<id>/transcript` 与 `/api/tasks/<id>/output` 必须能分页重建完整历史,不得因为热状态裁剪而丢失早期 trace。热 task JSON 只保留可配置窗口以保证 `/health`、`/api/tasks` 和 PostgreSQL flush 不被长任务拖死;主 server 为 Code Queue 放宽到 600M 容器预算后仍默认 `CODE_QUEUE_IN_MEMORY_OUTPUT_RECORDS=10`、`CODE_QUEUE_IN_MEMORY_EVENT_RECORDS=10`,启动时必须在 PostgreSQL 查询侧裁剪 hot output/events,并只 flush dirty task,禁止启动后无条件重写全量历史 task JSON;更高预算才允许调大热窗口。WebUI 必须支持多 queue 查看、显式创建 queue、提交时下拉选择 queue、提交时下拉选择执行 Provider,并支持把已创建且非 active 的任务移动到其他 queue;queue 内串行,queue 间默认并行且不互相排队;`CODE_QUEUE_MAX_ACTIVE_QUEUES` 仅作为显式配置的全局 active slot 上限,`0` 表示不按 queue 数量限流,内存不足时由 cgroup memory pressure 阻止新 run 并在任务响应中暴露 `QUEUED(MEM LIMIT)`。Code Queue 镜像必须内置 Playwright Chromium 浏览器与系统依赖,并使用 `bun --smol` 运行后端,保证队列任务能直接执行公网 frontend Playwright 回归且主进程内存可控,不得只在宿主机临时安装。日志写入 UniDesk `logs/{YYYYMMDD}/{startStamp}_{YYYYMMDD}_{HH}_code-queue.jsonl`,按小时切片并按日志族默认保留 `1GiB`;Codex app-server 上游产生的 `logs_*.sqlite` 只能作为短暂缓冲,必须由 Code Queue 周期性导出为 `logs/{YYYYMMDD}/{startStamp}_{YYYYMMDD}_{HH}_codex-app-server.jsonl`,导出后删除/压缩已导出的 SQLite 行,避免重新形成 `logs_2.sqlite` 大文件;`/logs` 端点返回最近结构化日志。`/health` 的 `queue.storage.primary` 必须恒为 `postgres`,并通过 `queue.storage.postgresReady`、`queue.devReady` 和 `/api/dev-ready` 暴露 PostgreSQL 可用性、develop-ready 自检、必需工具、Docker socket、`docker compose`、默认工作目录、Codex config 状态和 `/root/.ssh` 共享 SSH key 状态。Codex CLI-like 输出可能很大,服务必须节流状态持久化,禁止对每个 output delta 同步重写完整 state 导致 `/health` 和控制 API 卡死;容器 healthcheck 必须使用带超时的 HTTP 探针,不能留下堆积的无超时探针进程。
|
||||
- 状态与日志:`main-server` 默认工作目录为容器内 `/root/unidesk`,该路径映射主 server 的 `~/unidesk`;同时保留 `/workspace` 映射以兼容历史任务。非主 server Provider 的任务默认工作目录为 `/home/ubuntu`,任务 JSON、列表、Trace 摘要和 CLI 查询都必须显示 `providerId`、`executionMode` 与最终 `cwd`。`executionMode=default` 沿用主 server 本机 Codex 或远程执行容器 Codex;`executionMode=windows-native` 只允许非主 server WSL Provider、Codex 模型和 `/mnt/<drive>` 工作目录,Code Queue 仍会启动远程执行容器,但容器只运行 stdio relay,经 WSL bridge 调用 Windows 宿主原生 `codex app-server --listen stdio://`,避免公网/main-server 到 Provider 的临时断连直接杀死 Windows Codex 进程。Code Queue 的任务、queue、`readAt`/未读状态、attempt、judge、`promptHistory`、active session 元数据、控制状态和 ClaudeQQ 通知 outbox 一律以主 PostgreSQL 为权威,分别写入 `unidesk_code_queue_tasks`、`unidesk_code_queue_queues` 与 `unidesk_code_queue_notifications`;`DATABASE_URL` 是必需配置,服务不得在 PostgreSQL 缺失或不可用时进入文件存储模式。`.state/code-queue/state.json` 不再作为任务或 queue 状态存储,不得重新引入本地 JSON fallback;服务启动必须以 PostgreSQL 为唯一来源恢复队列,并把 running/judging 任务恢复为 retry_wait。主 server 内存很少,Code Queue 必须把“内存是稀缺资源”作为核心设计约束:历史任务列表、详情、统计和只读 Trace 查询优先从 PostgreSQL 直读,进程内只保留当前 running/judging、queued、retry_wait 等调度必需热任务,不得把全部历史 task JSON 长期缓存到 Bun 堆;需要短期热缓存时必须有严格上限、可裁剪、可从 PostgreSQL 和 append-only 输出归档重建。WebUI 不得用 browser `localStorage`、`sessionStorage` 或 IndexedDB 持久化 task/queue/readAt/unread 等业务状态;浏览器只能保留临时 UI 内存缓存,刷新后必须重新从后端读取 PostgreSQL 权威数据。Codex CLI-like output/Trace 的完整记录可以使用 append-only 文件作为日志型归档,但任务状态、未读状态和列表摘要不得依赖这些文件作为权威来源;`/api/tasks/<id>/transcript` 与 `/api/tasks/<id>/output` 必须能分页重建完整历史,不得因为热状态裁剪而丢失早期 trace。热 task JSON 只保留可配置窗口以保证 `/health`、`/api/tasks` 和 PostgreSQL flush 不被长任务拖死;主 server 为 Code Queue 放宽到 600M 容器预算后仍默认 `CODE_QUEUE_IN_MEMORY_OUTPUT_RECORDS=10`、`CODE_QUEUE_IN_MEMORY_EVENT_RECORDS=10`,启动时必须在 PostgreSQL 查询侧裁剪 hot output/events,并只 flush dirty task,禁止启动后无条件重写全量历史 task JSON;更高预算才允许调大热窗口。WebUI 必须支持多 queue 查看、显式创建 queue、提交时下拉选择 queue、提交时下拉选择执行 Provider 和执行模式,并支持把已创建且非 active 的任务移动到其他 queue;queue 内串行,queue 间默认并行且不互相排队;`CODE_QUEUE_MAX_ACTIVE_QUEUES` 仅作为显式配置的全局 active slot 上限,`0` 表示不按 queue 数量限流,内存不足时由 cgroup memory pressure 阻止新 run 并在任务响应中暴露 `QUEUED(MEM LIMIT)`。Code Queue 镜像必须内置 Playwright Chromium 浏览器与系统依赖,并使用 `bun --smol` 运行后端,保证队列任务能直接执行公网 frontend Playwright 回归且主进程内存可控,不得只在宿主机临时安装。日志写入 UniDesk `logs/{YYYYMMDD}/{startStamp}_{YYYYMMDD}_{HH}_code-queue.jsonl`,按小时切片并按日志族默认保留 `1GiB`;Codex app-server 上游产生的 `logs_*.sqlite` 只能作为短暂缓冲,必须由 Code Queue 周期性导出为 `logs/{YYYYMMDD}/{startStamp}_{YYYYMMDD}_{HH}_codex-app-server.jsonl`,导出后删除/压缩已导出的 SQLite 行,避免重新形成 `logs_2.sqlite` 大文件;`/logs` 端点返回最近结构化日志。`/health` 的 `queue.storage.primary` 必须恒为 `postgres`,并通过 `queue.storage.postgresReady`、`queue.devReady` 和 `/api/dev-ready` 暴露 PostgreSQL 可用性、develop-ready 自检、必需工具、Docker socket、`docker compose`、默认工作目录、Codex config 状态和 `/root/.ssh` 共享 SSH key 状态。Codex CLI-like 输出可能很大,服务必须节流状态持久化,禁止对每个 output delta 同步重写完整 state 导致 `/health` 和控制 API 卡死;容器 healthcheck 必须使用带超时的 HTTP 探针,不能留下堆积的无超时探针进程。
|
||||
- ClaudeQQ 通知:Code Queue 可通过 backend-core 的 `claudeqq` 用户服务代理调用 `POST /api/push/text`,在每个任务进入 `succeeded`、`failed` 或 `canceled` 终态后向配置目标发送最终 response,并附带 task id、queue、状态、模型、attempt、当前 running/queued/retry_wait 数和任务总耗时;当所有 queue 进入 `0 running / 0 queued` 空闲态时,必须单独发送一次空闲提醒。通知由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED` 控制,目标由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE=private|group`、`CODE_QUEUE_NOTIFY_CLAUDEQQ_USER_ID`、`CODE_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID` 配置,默认私聊 `645275593`;代理基址、最终 response 最大字符数、单次超时和发送尝试次数分别由 `CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL`、`CODE_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS`、`CODE_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS` 和 `CODE_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS` 配置。任务终态和队列空闲通知必须先写入 PostgreSQL outbox 表 `unidesk_code_queue_notifications` 再异步发送;不得使用 `.state/code-queue/claudeqq-notifications.json`、`CODE_QUEUE_NOTIFY_CLAUDEQQ_OUTBOX_PATH` 或任何本地 JSON 作为通知权威存储。发送失败、NapCat 离线、代理 502 或容器重启时不能丢通知,必须按 `CODE_QUEUE_NOTIFY_CLAUDEQQ_RETRY_INTERVAL_MS` 指数退避重试并跨进程/容器重启保留。`/health` 的 `queue.notifications.claudeqq` 必须暴露非敏感配置、目标配置状态和 PostgreSQL outbox 统计;`GET /api/notifications/claudeqq` 返回 outbox 明细,`POST /api/notifications/claudeqq/drain` 手动触发发送,`POST /api/notifications/claudeqq/backfill` 可按 `since` 补入某次故障窗口内已终态任务,确保 QQ/NapCat 超时或离线不会让任务完成通知永久丢失。
|
||||
- OA 接入:Code Queue 后端通过 `OA_EVENT_FLOW_BASE_URL=http://oa-event-flow:4255` 发布每个 TraceView 可见执行行的 `trace-step-created`、幂等种子/乱序校正用 `trace-stats-snapshot`、`task-updated` 和 queue 事件;服务启动或手动 backfill 时必须用相同 `eventId` 幂等回放历史 TraceView 可见执行行,避免历史任务停留在旧 STEP 统计口径。前端通过 `oa-event-flow` 的 `service:code-queue` tag stream 更新 STEP 和 Trace Summary,Code Queue 私有 SSE 不再作为刷新权威。`STEP` 表示 TraceView 可见执行行数,工具调用数必须由 `readCount+editCount+runCount` 展示,不能复用 `stepCount`。
|
||||
- OA 接入:Code Queue 后端通过 `OA_EVENT_FLOW_BASE_URL=http://oa-event-flow:4255` 发布每个 TraceView 可见执行行的 `trace-step-created`、幂等种子/乱序校正用 `trace-stats-snapshot`、`task-updated` 和 queue 事件;服务启动或手动 backfill 时必须用相同 `eventId` 幂等回放历史 TraceView 可见执行行,避免历史任务停留在旧 STEP 统计口径。前端通过 `oa-event-flow` 的 `service:code-queue` tag stream 更新 STEP 和 Trace Summary,Code Queue 私有 SSE 不再作为刷新权威。`STEP` 表示 TraceView 可见且非 system 的执行行数;system 行可保留在任务原始输出/数据库中,但默认不展示、不计入 STEP,工具调用数必须由 `readCount+editCount+runCount` 展示,不能复用 `stepCount`。
|
||||
- 代理路径:只允许 `/health`、`/logs` 和 `/api/` 前缀;允许方法为 `GET`、`HEAD`、`POST`、`DELETE`、`PATCH`。Code Queue 只在 Compose 内网暴露 `4222/tcp`,不得映射或开放到公网。
|
||||
- UniDesk 前端:`用户服务 / Code Queue` React 页面负责展示队列卡片、任务 ID、复制任务 ID、引用按钮、任务耗时、默认模型、模型下拉、执行 Provider 下拉、Provider 对应默认工作目录、显式入队份数、引用任务 ID、清空输入、创建成功提示、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call;Code Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;点击队列卡片引用按钮必须自动把该任务 ID 写入提交表单的引用任务 ID 输入框;引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示,让 Codex 读取初始 prompt、最后消息和工具摘要后继续;连续执行同一 prompt 应使用 `入队份数` 一次性生成多条队列任务,而不是依赖快速连点按钮;左侧 queue/session 卡片的 `QUEUED` 状态必须显示原因,例如 `QUEUED(PREV TASK)`、`QUEUED(MEM LIMIT)`、`QUEUED(ACTIVE LIMIT)`;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。
|
||||
- UniDesk 前端:`用户服务 / Code Queue` React 页面负责展示队列卡片、任务 ID、复制任务 ID、引用按钮、任务耗时、默认模型、模型下拉、执行 Provider 下拉、执行模式下拉、Provider/模式对应默认工作目录、显式入队份数、引用任务 ID、清空输入、创建成功提示、MiniMax judge 状态、Codex CLI-like 输出流、attempt 终态、追加 prompt、打断和手动重试控件;选择 `windows-native` 时应优先切到支持 Windows 原生 Codex 的非主 server Provider,并把工作目录提示切到 `/mnt/<drive>` 默认路径;整个 agent loop 消息流统一命名为专有名词 `Trace`,`Trace` 包含 assistant message、user prompt、system event 和 tool call;Code Queue 与 Pipeline/OpenCode messages 必须共用 `src/components/frontend/src/trace.tsx` 的 Trace 公共组件、统一 Trace item 接口和 codex/opencode port 适配层;连续 read/edit/run 工具调用只是在 Trace 内折叠为可展开工具调用组,汇总格式至少包含 `xx read, xx edit, xx run`,并展示读取文件、编辑文件、运行命令和耗时摘要;最近 3 个工具调用保持展开,工具调用内容不得自动换行且必须在工具调用块内部横向滚动,工具调用组展开后不得再增加额外左侧缩进;message 与 prompt 必须自动换行,普通 message 不显示左侧项目符号缩进且永不折叠;点击队列卡片引用按钮必须自动把该任务 ID 写入提交表单的引用任务 ID 输入框;引用任务 ID 创建新任务时必须自动注入 `bun scripts/cli.ts codex task <taskId>` 的提示,让 Codex 读取初始 prompt、最后消息和工具摘要后继续;连续执行同一 prompt 应使用 `入队份数` 一次性生成多条队列任务,而不是依赖快速连点按钮;左侧 queue/session 卡片的 `QUEUED` 状态必须显示原因,例如 `QUEUED(PREV TASK)`、`QUEUED(MEM LIMIT)`、`QUEUED(ACTIVE LIMIT)`;原始任务 JSON 只能通过显式 `查看原始JSON` 打开。
|
||||
|
||||
## D601 User Services
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Tag 是 OA 事件流的订阅和投影索引。所有 tag 必须是稳定字符
|
||||
|
||||
- Code Queue 不再维护独立 SSE 作为前端 Trace/STEP 刷新权威;任务输出、状态变更、queue 变更和统计快照必须发布到 `oa-event-flow`。
|
||||
- Code Queue 左侧 task card 的 `STEP`、选中 task 的 Trace Summary 和全局统计只能读取 `oa_trace_stats`;本地 task JSON 中的历史字段只能作为发布 snapshot 事件的输入,不作为前端权威统计来源。统计尚未投影完成时必须明确显示 `statsSource=unavailable` 或 `STEP --`,不得回退到 transcript、本地 `stepCount` 或前端重算。
|
||||
- 运行中每个新的 TraceView 可见执行行都必须发布 `trace-step-created`,并带 `task:<taskId>`、`queue:<queueId>`、`attempt:<index>`、`service:code-queue`、`trace` tag,以及 payload 中的 `scopeId=task:<taskId>`、`attemptIndex` 和 `attemptScopeId=task:<taskId>:attempt:<index>`;统计中心据此幂等更新任务级累计统计和 attempt 级独立统计,message/system/error 行只增加 STEP 或 error,不伪装为工具调用。
|
||||
- 运行中每个新的 TraceView 可见执行行都必须发布 `trace-step-created`,并带 `task:<taskId>`、`queue:<queueId>`、`attempt:<index>`、`service:code-queue`、`trace` tag,以及 payload 中的 `scopeId=task:<taskId>`、`attemptIndex` 和 `attemptScopeId=task:<taskId>:attempt:<index>`;统计中心据此幂等更新任务级累计统计和 attempt 级独立统计,message/error 行增加 STEP 或 error,system 行默认仅保留在任务原始输出/数据库中,不进入 STEP 计数且不伪装为工具调用。
|
||||
- Trace Summary 顶部执行摘要读取任务级 `task:<taskId>`;执行过程摘要 `#<index>` 读取 `task:<taskId>:attempt:<index>`。如果 attempt scope 尚未投影完成,必须显示 `statsSource=unavailable` 或 `--`,不得回退到任务级累计统计、transcript 重算或旧本地字段。
|
||||
- 任务入队、开始、终态、移动 queue、标记已读等状态事实必须发布 `task-updated` 或更具体的事实事件,供事件表和后续审计使用。
|
||||
- Code Queue 服务启动后可对 PostgreSQL 中已有任务回放每个 TraceView 可见执行行的 `trace-step-created`,并发布 `trace-stats-snapshot` 事件完成统计中心种子同步;回放必须使用相同 `eventId` 保持幂等,不得阻塞队列恢复。历史回放必须按 attempt start 行推导 `attemptIndex`,确保重建投影时 attempt scope 可独立恢复。
|
||||
|
||||
@@ -162,6 +162,17 @@ Keil/串口/board-comm 的通用顺序如下:
|
||||
|
||||
多 probe 同时在线时,`keil program`、`keil detect` 必须显式传 `-u <probe_uid>`。若 Keil UV4 backend 报缺少 flash/download metadata,可优先用 pyOCD backend 完成下载;是否补齐 UV4 工程下载配置应作为工程维护问题处理,而不是 SSH/Windows 透传问题。
|
||||
|
||||
## Code Queue Windows Native Codex
|
||||
|
||||
Code Queue 支持在提交任务时选择 `executionMode=windows-native`。该模式用于 D601 这类 WSL Provider:主 server 仍先通过既有远程执行容器建立任务运行环境,但容器内不启动 `codex`;容器只运行 stdio relay,连接到 WSL 侧 bridge,再由 bridge 调用 Windows 宿主原生 `codex app-server --listen stdio://`。这样公网/main-server 到 Provider 的临时断连不会直接终止 Windows Codex 进程。
|
||||
|
||||
约束:
|
||||
|
||||
- 只支持非主 server WSL Provider 和 Codex 模型;`minimax-m2.7` / OpenCode port 不走该模式。
|
||||
- 工作目录必须在 `/mnt/<drive>/...` 下,供 `win-cmd` 转换为 Windows 盘符 cwd;D601 默认提示为 `/mnt/f/Work/ConStart`。
|
||||
- Windows 侧必须已安装 `codex`,且 WSL wrapper `win-cmd` 可用;可用 `bun scripts/cli.ts ssh D601 -- 'export PATH="$HOME/.local/bin:$PATH"; win-cmd "where codex && codex --version"'` 验证。
|
||||
- 任务 JSON、列表、Trace 摘要和前端卡片必须显示 `executionMode`,便于区分默认容器 Codex 与 Windows 原生 Codex。
|
||||
|
||||
## Remote Frontend Limits
|
||||
|
||||
`--main-server-ip ... ssh <PROVIDER_ID> <command>` 走 frontend 登录态和 `host.ssh` dispatch,适合短命令和 skill discovery。它不流式转发 stdin,也受 provider-gateway 的 host.ssh command length 限制;`apply-patch`、`py < stdin`、超长 inline 脚本和需要完整终端流的操作应在 main server 本机 CLI 上执行,或显式走旧 SSH transport。
|
||||
|
||||
@@ -46,6 +46,7 @@ function help(): unknown {
|
||||
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
|
||||
{ command: "codex task <taskId> [--trace --tail|--from-start|--after-seq N|--before-seq N --limit N] [--full]", description: "Fetch a compact Code Queue task summary; trace rows are opt-in and paged with next/previous commands to avoid output explosion." },
|
||||
{ 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 by seq when a trace row has omitted command/output text." },
|
||||
{ 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 (queues | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>)", description: "List/create/merge Code Queue lanes and move a queued task; merge preserves task queue time order and deletes the source queue record." },
|
||||
{ command: "job list", description: "List async jobs from .state/jobs." },
|
||||
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
|
||||
|
||||
@@ -27,8 +27,15 @@ interface CodexOutputOptions {
|
||||
maxTextChars: number;
|
||||
}
|
||||
|
||||
type CodexResponseFetcher = (path: string) => unknown;
|
||||
type AsyncCodexResponseFetcher = (path: string) => Promise<unknown>;
|
||||
interface CodexJudgeOptions {
|
||||
attempt: number | null;
|
||||
dryRun: boolean;
|
||||
includePrompt: boolean;
|
||||
}
|
||||
|
||||
type CodexRequestInit = { method?: string; body?: unknown };
|
||||
type CodexResponseFetcher = (path: string, init?: CodexRequestInit) => unknown;
|
||||
type AsyncCodexResponseFetcher = (path: string, init?: CodexRequestInit) => Promise<unknown>;
|
||||
|
||||
function requireTaskId(value: string | undefined, command: string): string {
|
||||
if (value === undefined || value.trim().length === 0) throw new Error(`${command} requires task id`);
|
||||
@@ -432,6 +439,21 @@ function parseOutputOptions(args: string[]): CodexOutputOptions {
|
||||
};
|
||||
}
|
||||
|
||||
function parseJudgeOptions(args: string[]): CodexJudgeOptions {
|
||||
const rawAttempt = optionValue(args, ["--attempt", "--attempt-id", "--attemptIndex"]) ?? positionalArgs(args)[0];
|
||||
let attempt: number | null = null;
|
||||
if (rawAttempt !== undefined) {
|
||||
const value = Number(rawAttempt);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("--attempt must be a positive integer");
|
||||
attempt = value;
|
||||
}
|
||||
return {
|
||||
attempt,
|
||||
dryRun: hasFlag(args, "--dry-run") || hasFlag(args, "--no-call"),
|
||||
includePrompt: hasFlag(args, "--include-prompt"),
|
||||
};
|
||||
}
|
||||
|
||||
function queryString(params: Record<string, string | number | boolean | null | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@@ -506,6 +528,16 @@ function codexTaskOutput(taskId: string, options: CodexOutputOptions, fetcher: C
|
||||
return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) };
|
||||
}
|
||||
|
||||
function codexTaskJudge(taskId: string, options: CodexJudgeOptions, fetcher: CodexResponseFetcher): unknown {
|
||||
const params = queryString({
|
||||
attempt: options.attempt,
|
||||
dryRun: options.dryRun ? 1 : undefined,
|
||||
includePrompt: options.includePrompt ? 1 : undefined,
|
||||
});
|
||||
const response = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/judge${params}`, { method: "POST" }));
|
||||
return { upstream: response.upstream, judgeReplay: response.body };
|
||||
}
|
||||
|
||||
export function codexTaskQuery(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
return codexTaskSummary(taskId, parseTaskOptions(optionArgs), fetcher);
|
||||
}
|
||||
@@ -514,6 +546,10 @@ export function codexOutputQuery(taskId: string, optionArgs: string[], fetcher:
|
||||
return codexTaskOutput(taskId, parseOutputOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
export function codexJudgeQuery(taskId: string, optionArgs: string[], fetcher: CodexResponseFetcher = coreInternalFetch): unknown {
|
||||
return codexTaskJudge(taskId, parseJudgeOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const summaryPath = `/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`;
|
||||
const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath));
|
||||
@@ -548,6 +584,16 @@ async function codexTaskOutputAsync(taskId: string, options: CodexOutputOptions,
|
||||
return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) };
|
||||
}
|
||||
|
||||
async function codexTaskJudgeAsync(taskId: string, options: CodexJudgeOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
const params = queryString({
|
||||
attempt: options.attempt,
|
||||
dryRun: options.dryRun ? 1 : undefined,
|
||||
includePrompt: options.includePrompt ? 1 : undefined,
|
||||
});
|
||||
const response = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/judge${params}`, { method: "POST" }));
|
||||
return { upstream: response.upstream, judgeReplay: response.body };
|
||||
}
|
||||
|
||||
export async function codexTaskQueryAsync(taskId: string, optionArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
return codexTaskSummaryAsync(taskId, parseTaskOptions(optionArgs), fetcher);
|
||||
}
|
||||
@@ -556,6 +602,10 @@ export async function codexOutputQueryAsync(taskId: string, optionArgs: string[]
|
||||
return codexTaskOutputAsync(taskId, parseOutputOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
export async function codexJudgeQueryAsync(taskId: string, optionArgs: string[], fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
|
||||
return codexTaskJudgeAsync(taskId, parseJudgeOptions(optionArgs), fetcher);
|
||||
}
|
||||
|
||||
function requireQueueId(args: string[], command: string): string {
|
||||
const index = args.indexOf("--queue");
|
||||
const raw = index === -1 ? args[0] : args[index + 1];
|
||||
@@ -625,6 +675,10 @@ export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]
|
||||
const taskId = requireTaskId(taskIdArg, "codex output");
|
||||
return codexOutputQuery(taskId, args.slice(2));
|
||||
}
|
||||
if (action === "judge") {
|
||||
const taskId = requireTaskId(taskIdArg, "codex judge");
|
||||
return codexJudgeQuery(taskId, args.slice(2));
|
||||
}
|
||||
if (action === "queues") return codeQueues();
|
||||
if (action === "queue") {
|
||||
const sub = taskIdArg ?? "list";
|
||||
@@ -639,5 +693,5 @@ export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]
|
||||
const taskId = requireTaskId(taskIdArg, "codex move");
|
||||
return codexMoveTask(taskId, requireQueueId(args.slice(2), "codex move"));
|
||||
}
|
||||
throw new Error("codex command must be one of: task, summary, show, output, queues, queue list, queue create, queue merge, move");
|
||||
throw new Error("codex command must be one of: task, summary, show, output, judge, queues, queue list, queue create, queue merge, move");
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
const previousRaw = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||
const previousValue = (key: string): string => previousRaw.match(new RegExp(`^${key}=(.*)$`, "m"))?.[1]?.replace(/^"|"$/g, "") ?? "";
|
||||
const runtimeSecret = (key: string): string => process.env[key] ?? previousValue(key);
|
||||
const runtimeSecretWithDefault = (key: string, defaultValue: string, legacyDefault = ""): string => {
|
||||
if (process.env[key] !== undefined) return process.env[key] ?? defaultValue;
|
||||
const previous = previousValue(key);
|
||||
if (previous.length > 0 && previous !== legacyDefault) return previous;
|
||||
return defaultValue;
|
||||
};
|
||||
let logRoot: string;
|
||||
let logDay: string;
|
||||
let logPrefix: string;
|
||||
@@ -134,7 +140,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_CODE_QUEUE_MINIMAX_API_KEY: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_API_KEY") || runtimeSecret("MINIMAX_API_KEY"),
|
||||
UNIDESK_CODE_QUEUE_MINIMAX_MODEL: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_MODEL") || runtimeSecret("MINIMAX_MODEL") || "MiniMax-M2.7",
|
||||
UNIDESK_CODE_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_API_BASE") || runtimeSecret("MINIMAX_API_BASE") || "https://api.minimaxi.com/v1",
|
||||
UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS") || "60000",
|
||||
UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecretWithDefault("UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS", "90000", "60000"),
|
||||
UNIDESK_CODE_QUEUE_REMOTE_WORKDIR: runtimeSecret("UNIDESK_CODE_QUEUE_REMOTE_WORKDIR") || "/home/ubuntu",
|
||||
UNIDESK_CODE_QUEUE_EXECUTION_PROVIDER_IDS: runtimeSecret("UNIDESK_CODE_QUEUE_EXECUTION_PROVIDER_IDS") || "D601",
|
||||
UNIDESK_CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID: runtimeSecret("UNIDESK_CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID") || "D601",
|
||||
|
||||
+15
-5
@@ -3,7 +3,7 @@ import { type UniDeskConfig } from "./config";
|
||||
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
|
||||
import { summarizeMicroserviceProxyResponse } from "./microservices";
|
||||
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
|
||||
import { codexOutputQueryAsync, codexTaskQueryAsync } from "./code-queue";
|
||||
import { codexJudgeQueryAsync, codexOutputQueryAsync, codexTaskQueryAsync } from "./code-queue";
|
||||
|
||||
export interface RemoteCliOptions {
|
||||
host: string | null;
|
||||
@@ -470,16 +470,26 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
|
||||
|
||||
async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise<unknown> {
|
||||
const action = args[1] ?? "task";
|
||||
if (action !== "task" && action !== "summary" && action !== "show" && action !== "output") {
|
||||
throw new Error("remote codex command must be: codex task <taskId> or codex output <taskId>");
|
||||
if (action !== "task" && action !== "summary" && action !== "show" && action !== "output" && action !== "judge") {
|
||||
throw new Error("remote codex command must be: codex task <taskId>, codex output <taskId>, or codex judge <taskId> --attempt N");
|
||||
}
|
||||
const taskId = args[2];
|
||||
if (taskId === undefined || taskId.length === 0) throw new Error(`codex ${action} requires task id`);
|
||||
const fetcher = (path: string): Promise<FetchJsonResult> => frontendJson(session, path, undefined, 24_000);
|
||||
const fetcher = (path: string, init?: { method?: string; body?: unknown }): Promise<FetchJsonResult> => {
|
||||
const requestInit = init === undefined
|
||||
? undefined
|
||||
: {
|
||||
method: init.method,
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
};
|
||||
return frontendJson(session, path, requestInit, action === "judge" ? 130_000 : 24_000);
|
||||
};
|
||||
return {
|
||||
transport: "frontend",
|
||||
result: action === "output"
|
||||
? await codexOutputQueryAsync(taskId, args.slice(3), fetcher)
|
||||
: action === "judge"
|
||||
? await codexJudgeQueryAsync(taskId, args.slice(3), fetcher)
|
||||
: await codexTaskQueryAsync(taskId, args.slice(3), fetcher),
|
||||
};
|
||||
}
|
||||
@@ -538,7 +548,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", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "codex task <taskId>"],
|
||||
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "ssh <providerId> skills", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>", "codex task <taskId>", "codex judge <taskId> --attempt N"],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3181,6 +3181,15 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
|
||||
color: var(--accent-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.codex-trace-step-inline-summary {
|
||||
min-width: 80px;
|
||||
flex: 1 1 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #a7c7c3;
|
||||
font-size: 11px;
|
||||
}
|
||||
.codex-trace-step.error > summary .codex-output-channel {
|
||||
color: var(--danger);
|
||||
border-color: rgba(207, 106, 84, 0.52);
|
||||
|
||||
@@ -655,6 +655,40 @@ function objectRecord(value: any): AnyRecord | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function judgeFailureDetailsText(judge: any): string {
|
||||
const raw = objectRecord(judge?.raw);
|
||||
const details = objectRecord(judge?.failureDetails) || objectRecord(raw?.minimaxFailure);
|
||||
if (details === null) return "";
|
||||
const repairAttempt = details.repairAttempt === undefined ? "" : `${details.repairAttempt}/${details.maxRepairAttempts ?? "?"}`;
|
||||
const rows = [
|
||||
["provider", details.provider || "minimax"],
|
||||
["stage", details.stage],
|
||||
["model", details.model],
|
||||
["timedOut", details.timedOut],
|
||||
["durationMs", details.durationMs],
|
||||
["timeoutMs", details.timeoutMs],
|
||||
["promptChars", details.promptChars],
|
||||
["promptLines", details.promptLines],
|
||||
["payloadBytes", details.payloadBytes],
|
||||
["responseStatus", details.responseStatus],
|
||||
["repairAttempt", repairAttempt],
|
||||
["errorName", details.errorName],
|
||||
["error", details.errorMessage],
|
||||
["responseContentPreview", details.responseContentPreview],
|
||||
["responseTextPreview", details.responseTextPreview],
|
||||
].filter(([, value]) => value !== undefined && value !== null && String(value).length > 0);
|
||||
return rows.map(([key, value]) => `${key}: ${String(value)}`).join("\n");
|
||||
}
|
||||
|
||||
function JudgeFailureDetails({ judge, testId = "codex-judge-failure-details" }: AnyRecord) {
|
||||
const text = judgeFailureDetailsText(judge);
|
||||
if (text.length === 0) return null;
|
||||
return h("details", { className: "codex-judge-failure-details", "data-testid": testId },
|
||||
h("summary", null, "MiniMax failure details"),
|
||||
h("pre", null, text),
|
||||
);
|
||||
}
|
||||
|
||||
function taskProgressiveAttempts(task: any): any[] {
|
||||
const summaryAttempts = taskTraceSummary(task)?.attempts;
|
||||
if (Array.isArray(summaryAttempts) && summaryAttempts.length > 0) return summaryAttempts;
|
||||
@@ -1214,6 +1248,8 @@ function codexProviderOptions(queue: any, currentProviderId: string): any[] {
|
||||
id: String(item?.id || "").trim(),
|
||||
label: String(item?.label || item?.id || "").trim(),
|
||||
defaultWorkdir: String(item?.defaultWorkdir || "").trim(),
|
||||
supportsWindowsNativeCodex: item?.supportsWindowsNativeCodex === true,
|
||||
windowsNativeDefaultWorkdir: String(item?.windowsNativeDefaultWorkdir || "").trim(),
|
||||
kind: String(item?.kind || "").trim(),
|
||||
}))
|
||||
.filter((item: any) => item.id.length > 0);
|
||||
@@ -1221,14 +1257,44 @@ function codexProviderOptions(queue: any, currentProviderId: string): any[] {
|
||||
const byId = new Map<string, any>();
|
||||
for (const item of [
|
||||
...rows,
|
||||
{ id: fallbackMain, label: `${fallbackMain} (master)`, defaultWorkdir: String(queue?.defaultWorkdir || "/root/unidesk"), kind: "local" },
|
||||
currentProviderId ? { id: currentProviderId, label: currentProviderId, defaultWorkdir: providerDefaultWorkdir(queue, currentProviderId), kind: "" } : null,
|
||||
{ id: fallbackMain, label: `${fallbackMain} (master)`, defaultWorkdir: String(queue?.defaultWorkdir || "/root/unidesk"), supportsWindowsNativeCodex: false, windowsNativeDefaultWorkdir: "", kind: "local" },
|
||||
currentProviderId ? { id: currentProviderId, label: currentProviderId, defaultWorkdir: providerDefaultWorkdir(queue, currentProviderId), supportsWindowsNativeCodex: currentProviderId !== fallbackMain, windowsNativeDefaultWorkdir: String(queue?.windowsNativeCodexDefaultWorkdir || "/mnt/f/Work/ConStart"), kind: "" } : null,
|
||||
].filter(Boolean) as any[]) {
|
||||
if (!byId.has(item.id)) byId.set(item.id, item);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function codexExecutionModeOptions(queue: any, currentMode: string): any[] {
|
||||
const configured = Array.isArray(queue?.executionModes) ? queue.executionModes : [];
|
||||
const rows = configured
|
||||
.map((item: any) => ({
|
||||
id: String(item?.id || item?.kind || "").trim(),
|
||||
label: String(item?.label || item?.id || item?.kind || "").trim(),
|
||||
description: String(item?.description || "").trim(),
|
||||
defaultWorkdir: String(item?.defaultWorkdir || "").trim(),
|
||||
requiresProvider: item?.requiresProvider === true,
|
||||
requiresWindowsCwd: item?.requiresWindowsCwd === true,
|
||||
}))
|
||||
.filter((item: any) => item.id.length > 0);
|
||||
const fallback = [
|
||||
{ id: "default", label: "默认容器/本机", description: "主 server 用本机 Codex;远程 Provider 用执行容器 Codex。", defaultWorkdir: "", requiresProvider: false, requiresWindowsCwd: false },
|
||||
{ id: "windows-native", label: "Windows 原生 Codex", description: "启动执行容器,但容器只做 stdio relay,Codex 运行在 Provider 的 Windows 宿主。", defaultWorkdir: String(queue?.windowsNativeCodexDefaultWorkdir || "/mnt/f/Work/ConStart"), requiresProvider: true, requiresWindowsCwd: true },
|
||||
];
|
||||
const byId = new Map<string, any>();
|
||||
for (const item of [...rows, ...fallback, currentMode ? { id: currentMode, label: currentMode, description: "", defaultWorkdir: "", requiresProvider: currentMode === "windows-native", requiresWindowsCwd: currentMode === "windows-native" } : null].filter(Boolean) as any[]) {
|
||||
if (!byId.has(item.id)) byId.set(item.id, item);
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function executionModeDefaultWorkdir(queue: any, mode: string, providerId: string): string {
|
||||
if (mode !== "windows-native") return providerDefaultWorkdir(queue, providerId);
|
||||
const option = Array.isArray(queue?.executionModes) ? queue.executionModes.find((item: any) => String(item?.id || item?.kind || "") === "windows-native") : null;
|
||||
const provider = Array.isArray(queue?.executionProviders) ? queue.executionProviders.find((item: any) => String(item?.id || "") === providerId) : null;
|
||||
return String(provider?.windowsNativeDefaultWorkdir || option?.defaultWorkdir || queue?.windowsNativeCodexDefaultWorkdir || "/mnt/f/Work/ConStart");
|
||||
}
|
||||
|
||||
function providerDefaultWorkdir(queue: any, providerId: string): string {
|
||||
const id = String(providerId || "").trim();
|
||||
const map = queue?.defaultWorkdirByProvider && typeof queue.defaultWorkdirByProvider === "object" ? queue.defaultWorkdirByProvider : {};
|
||||
@@ -1310,6 +1376,7 @@ function TaskCard({ task, selected, onSelect, onCopy, onReference, onMarkRead, c
|
||||
h("div", { className: "codex-task-meta" },
|
||||
h("span", null, `queue=${taskQueueLabel(task)}`),
|
||||
h("span", null, `provider=${task?.providerId || "main-server"}`),
|
||||
h("span", null, `mode=${task?.executionMode || "default"}`),
|
||||
h("span", null, task?.model || "--"),
|
||||
h("span", null, taskDurationLabel(task)),
|
||||
),
|
||||
@@ -1622,6 +1689,7 @@ function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onL
|
||||
const seq = String(step?.seq ?? "");
|
||||
const detail = stepDetails[seq];
|
||||
const summaryLines = Array.isArray(step?.summaryLines) ? step.summaryLines.slice(0, 4) : [];
|
||||
const inlineSummary = summaryLines.find((line: any) => String(line || "").trim().length > 0);
|
||||
return h("details", {
|
||||
key: seq || `${step?.title}-${step?.at}`,
|
||||
className: `codex-trace-step ${String(step?.kind || "message")} ${traceStepIsError(step) ? "error" : ""}`,
|
||||
@@ -1634,6 +1702,7 @@ function ProgressiveExecutionSummary({ task, attempt, attemptIndex, loading, onL
|
||||
h("span", { className: "codex-output-channel" }, traceStepKindLabel(step?.kind)),
|
||||
h("strong", null, String(step?.title || "Trace step")),
|
||||
step?.status ? h("code", null, String(step.status)) : null,
|
||||
inlineSummary ? h("span", { className: "codex-trace-step-inline-summary", title: String(inlineSummary) }, String(inlineSummary)) : null,
|
||||
h("time", null, fmtDate(step?.at)),
|
||||
),
|
||||
h("div", { className: "codex-trace-step-summary" },
|
||||
@@ -1695,6 +1764,7 @@ function ProgressiveJudge({ task, attempt, attemptIndex, testId = "codex-progres
|
||||
h(StatusBadge, { status: judge.decision }, judge.decision),
|
||||
h("strong", null, `${Math.round(Number(judge.confidence || 0) * 100)}% confidence`),
|
||||
h("p", { "data-testid": `${testId}-reason` }, judge.reason || "--"),
|
||||
h(JudgeFailureDetails, { judge, testId: `${testId}-failure-details` }),
|
||||
judge.continuePrompt ? h("pre", { "data-testid": `${testId}-continue-prompt` }, String(judge.continuePrompt || "")) : null,
|
||||
),
|
||||
);
|
||||
@@ -1889,6 +1959,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
|
||||
const [mergeSourceQueueId, setMergeSourceQueueId] = useState("");
|
||||
const [providerId, setProviderId] = useState("main-server");
|
||||
const [executionMode, setExecutionMode] = useState("default");
|
||||
const [model, setModel] = useState("gpt-5.5");
|
||||
const [cwd, setCwd] = useState("/root/unidesk");
|
||||
const [maxAttempts, setMaxAttempts] = useState(99);
|
||||
@@ -1967,7 +2038,8 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
const submitDisabled = submitting || busy || enqueueCount === 0 || batchNeedsConfirmation;
|
||||
const codexModels = codexModelOptions(queue, model);
|
||||
const providerOptions = codexProviderOptions(queue, providerId);
|
||||
const currentProviderDefaultWorkdir = providerDefaultWorkdir(queue, providerId);
|
||||
const executionModeRows = codexExecutionModeOptions(queue, executionMode);
|
||||
const currentProviderDefaultWorkdir = executionModeDefaultWorkdir(queue, executionMode, providerId);
|
||||
const selectedCanSteer = selectedTask?.id && selectedTask?.activeTurnId && String(selectedTask?.status) === "running";
|
||||
const selectedCanInterrupt = selectedTask?.id && !["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
const selectedCanRetry = selectedTask?.id && ["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
|
||||
@@ -2038,7 +2110,21 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
function changeSubmitProvider(nextProviderId: string): void {
|
||||
const next = String(nextProviderId || queue?.mainProviderId || "main-server").trim() || "main-server";
|
||||
setProviderId(next);
|
||||
setCwd(providerDefaultWorkdir(queue, next));
|
||||
setCwd(executionModeDefaultWorkdir(queue, executionMode, next));
|
||||
}
|
||||
|
||||
function changeSubmitExecutionMode(nextMode: string): void {
|
||||
const next = String(nextMode || "default").trim() || "default";
|
||||
let nextProvider = providerId;
|
||||
if (next === "windows-native") {
|
||||
const current = providerOptions.find((provider: any) => provider.id === providerId);
|
||||
if (!current?.supportsWindowsNativeCodex) {
|
||||
nextProvider = String(providerOptions.find((provider: any) => provider.supportsWindowsNativeCodex)?.id || providerId || "D601");
|
||||
setProviderId(nextProvider);
|
||||
}
|
||||
}
|
||||
setExecutionMode(next);
|
||||
setCwd(executionModeDefaultWorkdir(queue, next, nextProvider));
|
||||
}
|
||||
|
||||
function patchLoadedReadState(taskIds: string[], readAt: string, queuePatch: any = null, taskPatch: any = null): void {
|
||||
@@ -2872,6 +2958,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
prompt: text,
|
||||
queueId: submitQueueId,
|
||||
providerId,
|
||||
executionMode,
|
||||
model,
|
||||
cwd,
|
||||
maxAttempts: Number(maxAttempts),
|
||||
@@ -3298,6 +3385,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
h("span", { className: `codex-trace-status-chip unread ${overallUnreadTerminalCount > 0 ? "warn" : ""}` }, h("b", null, "结束未读"), String(overallUnreadTerminalCount)),
|
||||
h("span", { className: "codex-trace-status-chip service" }, h("b", null, "服务"), `${runtime.providerStatus || "unknown"} · ${service?.providerId || "main-server"} · ${backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"}`),
|
||||
h("span", { className: "codex-trace-status-chip" }, h("b", null, "执行节点"), providerOptions.map((provider: any) => provider.id).join(" / ")),
|
||||
h("span", { className: "codex-trace-status-chip" }, h("b", null, "执行模式"), executionModeRows.map((mode: any) => mode.id).join(" / ")),
|
||||
h("span", { className: "codex-trace-status-chip" }, h("b", null, "模型"), codexModels.join(" / ")),
|
||||
h("span", { className: "codex-trace-status-chip" }, h("b", null, "加载"), loadStats?.phase === "complete" ? fmtPreciseMs(loadStats?.totalMs) : String(loadStats?.phase || "idle")),
|
||||
h("span", { className: "codex-trace-status-chip" }, h("b", null, "刷新"), refreshedAt ? fmtClock(refreshedAt) : "--"),
|
||||
@@ -3305,7 +3393,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
|
||||
const sessionPanel = h(Panel, {
|
||||
title: selectedTask ? `Trace ${String(selectedTask.id).slice(0, 22)}` : "Trace 输出",
|
||||
eyebrow: selectedTask ? `${selectedTask.status} / view=${selectedQueueName} / task queue=${taskQueueLabel(selectedTask)} / provider=${selectedTask.providerId || "main-server"} / ${selectedTask.model} / agent loop trace` : `Agent loop trace / view=${selectedQueueName}`,
|
||||
eyebrow: selectedTask ? `${selectedTask.status} / view=${selectedQueueName} / task queue=${taskQueueLabel(selectedTask)} / provider=${selectedTask.providerId || "main-server"} / mode=${selectedTask.executionMode || "default"} / ${selectedTask.model} / agent loop trace` : `Agent loop trace / view=${selectedQueueName}`,
|
||||
summary: traceStatusSummary,
|
||||
loading: selectedDetailLoading || loadingMoreTasks || searchLoading || searchLoadingMoreTasks || loadStats?.phase === "loading",
|
||||
actions: h("div", { className: "panel-actions" },
|
||||
@@ -3433,7 +3521,12 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
),
|
||||
h("label", null, "执行 Provider",
|
||||
h("select", { value: providerId, disabled: submitting, onChange: (event: any) => changeSubmitProvider(String(event.target.value || "main-server")), "data-testid": "codex-provider-select" },
|
||||
providerOptions.map((provider: any) => h("option", { key: provider.id, value: provider.id }, `${provider.label || provider.id} · ${provider.defaultWorkdir || providerDefaultWorkdir(queue, provider.id)}`)),
|
||||
providerOptions.map((provider: any) => h("option", { key: provider.id, value: provider.id }, `${provider.label || provider.id} · ${provider.defaultWorkdir || providerDefaultWorkdir(queue, provider.id)}${provider.supportsWindowsNativeCodex ? " · Windows native" : ""}`)),
|
||||
),
|
||||
),
|
||||
h("label", null, "执行模式",
|
||||
h("select", { value: executionMode, disabled: submitting, onChange: (event: any) => changeSubmitExecutionMode(String(event.target.value || "default")), "data-testid": "codex-execution-mode-select" },
|
||||
executionModeRows.map((mode: any) => h("option", { key: mode.id, value: mode.id }, `${mode.label || mode.id}${mode.id === "windows-native" ? " · 宿主 Codex" : ""}`)),
|
||||
),
|
||||
),
|
||||
h("label", null, "工作目录", h("input", { value: cwd, disabled: submitting, onChange: (event: any) => setCwd(event.target.value), placeholder: currentProviderDefaultWorkdir || queue?.defaultWorkdir || "/root/unidesk", "data-testid": "codex-cwd-input" })),
|
||||
@@ -3530,6 +3623,7 @@ export function CodeQueuePage({ microservices, onRaw, apiBaseUrl = "/api", initi
|
||||
h(StatusBadge, { status: selectedTask.lastJudge.decision }, selectedTask.lastJudge.decision),
|
||||
h("strong", null, `${Math.round(Number(selectedTask.lastJudge.confidence || 0) * 100)}% confidence`),
|
||||
h("p", { "data-testid": "codex-task-judge-reason" }, shortText(selectedTask.lastJudge.reason || "--", 180)),
|
||||
h(JudgeFailureDetails, { judge: selectedTask.lastJudge, testId: "codex-task-judge-failure-details" }),
|
||||
selectedTask.lastJudge.continuePrompt ? h("code", { "data-testid": "codex-task-judge-continue-prompt" }, shortText(selectedTask.lastJudge.continuePrompt, 160)) : null,
|
||||
) : h(EmptyState, { title: "尚未判定", text: "Codex turn 结束后会由 MiniMax M2.7 或 fallback judge 判定 complete/retry/fail;retry 会在已有 thread 追加继续执行 prompt。" }),
|
||||
),
|
||||
|
||||
@@ -864,6 +864,28 @@ function renderToolGroup(item: TraceItem): any {
|
||||
);
|
||||
}
|
||||
|
||||
function traceSystemItemIsError(item: TraceItem): boolean {
|
||||
const text = [
|
||||
item.title,
|
||||
item.status,
|
||||
item.bodyPreview,
|
||||
item.commandPreview,
|
||||
item.stderrPreview,
|
||||
item.stdoutPreview,
|
||||
].map((value) => String(value || "")).join("\n");
|
||||
return /\b(error|failed|failure|interrupt|interrupted|cancell?ed|watchdog|timeout|closed|refused|aborted|exception)\b/iu.test(text);
|
||||
}
|
||||
|
||||
function filterTraceSystemEvents(items: TraceItem[], showSystemEvents: boolean): TraceItem[] {
|
||||
if (showSystemEvents) return items;
|
||||
return items.flatMap((item) => {
|
||||
if (String(item.kind || "") === "system" && !traceSystemItemIsError(item)) return [];
|
||||
if (String(item.kind || "") !== "toolGroup" || !Array.isArray(item.items)) return [item];
|
||||
const visibleItems = filterTraceSystemEvents(item.items, showSystemEvents);
|
||||
return [{ ...item, items: visibleItems }];
|
||||
});
|
||||
}
|
||||
|
||||
const traceAutoScrollBottomThresholdPx = 16;
|
||||
|
||||
function traceIsScrolledToBottom(element: HTMLElement): boolean {
|
||||
@@ -871,10 +893,10 @@ function traceIsScrolledToBottom(element: HTMLElement): boolean {
|
||||
return distanceToBottom <= traceAutoScrollBottomThresholdPx;
|
||||
}
|
||||
|
||||
export function TraceView({ items, input, port, autoScroll = false, loading = false, hasDetail = true, emptyText = "等待 Trace 输出...", loadingText = "正在加载完整 Trace...", testId = "trace-output", className = "codex-transcript", keepRecentToolCalls = 3, collapseTools = true }: AnyRecord) {
|
||||
export function TraceView({ items, input, port, autoScroll = false, loading = false, hasDetail = true, emptyText = "等待 Trace 输出...", loadingText = "正在加载完整 Trace...", testId = "trace-output", className = "codex-transcript", keepRecentToolCalls = 3, collapseTools = true, showSystemEvents = false }: AnyRecord) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const shouldFollowTailRef = useRef(true);
|
||||
const rawTrace = coalesceEditedFileChanges(port ? traceFromPort(port, input) : normalizeTraceItems(items));
|
||||
const rawTrace = filterTraceSystemEvents(coalesceEditedFileChanges(port ? traceFromPort(port, input) : normalizeTraceItems(items)), Boolean(showSystemEvents));
|
||||
const trace = collapseTools ? collapseToolTraceRuns(rawTrace, keepRecentToolCalls) : rawTrace;
|
||||
const maxSeq = traceMaxSeq(rawTrace);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface CodexPortContext {
|
||||
recordNumberField: (record: Record<string, unknown> | null, keys: string[]) => number | null;
|
||||
recordStringField: (record: Record<string, unknown> | null, keys: string[]) => string;
|
||||
remoteAppServerCommand: (task: QueueTask) => string;
|
||||
windowsNativeAppServerCommand: (task: QueueTask) => string;
|
||||
resolveReasoningEffort: (model: string, explicit?: string | null) => string | null;
|
||||
safePreview: (value: string, max?: number) => string;
|
||||
nowIso: () => string;
|
||||
@@ -36,6 +37,18 @@ function ctx(): CodexPortContext {
|
||||
return context;
|
||||
}
|
||||
|
||||
function windowsPathFromWslMount(path: string): string {
|
||||
const match = path.match(/^\/mnt\/([a-zA-Z])(?:\/(.*))?$/u);
|
||||
if (match === null) return path;
|
||||
const drive = String(match[1]).toUpperCase();
|
||||
const rest = String(match[2] ?? "").replace(/\//gu, "\\");
|
||||
return rest.length > 0 ? `${drive}:\\${rest}` : `${drive}:\\`;
|
||||
}
|
||||
|
||||
function appServerCwdForTask(task: QueueTask): string {
|
||||
return task.executionMode === "windows-native" ? windowsPathFromWslMount(task.cwd) : task.cwd;
|
||||
}
|
||||
|
||||
class AppServerClient {
|
||||
private child: ChildProcessWithoutNullStreams;
|
||||
private nextId = 1;
|
||||
@@ -48,13 +61,16 @@ class AppServerClient {
|
||||
|
||||
constructor(private readonly task: QueueTask, private readonly onNotification: (message: Record<string, unknown>) => void) {
|
||||
this.closedPromise = new Promise((resolveClosed) => { this.closeResolve = resolveClosed; });
|
||||
const remoteCommand = task.executionMode === "windows-native"
|
||||
? ctx().windowsNativeAppServerCommand(task)
|
||||
: ctx().remoteAppServerCommand(task);
|
||||
this.child = ctx().providerIsMain(task.providerId)
|
||||
? spawn("codex", ["app-server", "--listen", "stdio://"], {
|
||||
cwd: task.cwd,
|
||||
env: { ...process.env, CODEX_HOME: ctx().config.codexHome, CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "unidesk_code_queue" },
|
||||
stdio: "pipe",
|
||||
})
|
||||
: spawn("bun", ["scripts/cli.ts", "ssh", task.providerId, ctx().remoteAppServerCommand(task)], {
|
||||
: spawn("bun", ["scripts/cli.ts", "ssh", task.providerId, remoteCommand], {
|
||||
cwd: ctx().config.defaultWorkdir,
|
||||
env: process.env,
|
||||
stdio: "pipe",
|
||||
@@ -83,7 +99,7 @@ class AppServerClient {
|
||||
const response = await this.request("thread/resume", {
|
||||
threadId: this.task.codexThreadId,
|
||||
model: this.task.model,
|
||||
cwd: this.task.cwd,
|
||||
cwd: appServerCwdForTask(this.task),
|
||||
approvalPolicy: ctx().config.approvalPolicy,
|
||||
sandbox: ctx().config.sandbox,
|
||||
});
|
||||
@@ -94,7 +110,7 @@ class AppServerClient {
|
||||
}
|
||||
const response = await this.request("thread/start", {
|
||||
model: this.task.model,
|
||||
cwd: this.task.cwd,
|
||||
cwd: appServerCwdForTask(this.task),
|
||||
approvalPolicy: ctx().config.approvalPolicy,
|
||||
sandbox: ctx().config.sandbox,
|
||||
serviceName: "unidesk-code-queue",
|
||||
@@ -108,7 +124,7 @@ class AppServerClient {
|
||||
const params: Record<string, JsonValue> = {
|
||||
threadId,
|
||||
input: textInput(prompt),
|
||||
cwd: this.task.cwd,
|
||||
cwd: appServerCwdForTask(this.task),
|
||||
approvalPolicy: ctx().config.approvalPolicy,
|
||||
model: this.task.model,
|
||||
};
|
||||
@@ -324,6 +340,9 @@ export async function runCodexTurn(task: QueueTask, prompt: string): Promise<Cod
|
||||
let terminalResolve!: () => void;
|
||||
const terminalPromise = new Promise<void>((resolveTerminal) => { terminalResolve = resolveTerminal; });
|
||||
await ctx().ensureTaskExecutionContainer(task);
|
||||
if (task.executionMode === "windows-native") {
|
||||
ctx().appendOutput(task, "system", "windows-native execution: container stdio relay will connect to Codex on the provider Windows host\n", "execution/mode");
|
||||
}
|
||||
const app = new AppServerClient(task, (message) => {
|
||||
const method = typeof message.method === "string" ? message.method : "unknown";
|
||||
if (method !== "thread/started" && method !== "turn/started") lastAppActivityAt = Date.now();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 重构前 index.ts 只读参考:commit 6a04144d3f5103014f75b637d7e6bc2f45bf007f,blob 56e590c1a6b5ca7ad128bf2c992f60e46c355a58;可用 `git show 6a04144d3f5103014f75b637d7e6bc2f45bf007f:src/components/microservices/code-queue/src/index.ts` 查看。
|
||||
|
||||
import type { JsonValue } from "../types";
|
||||
import type { CodeExecutionMode, JsonValue } from "../types";
|
||||
|
||||
export type CodeAgentPortKind = "codex" | "opencode";
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface ActiveRunSlotWaiter {
|
||||
export const minimaxM27Model = "minimax-m2.7";
|
||||
export const defaultCodeModels = ["gpt-5.5", "gpt-5.4-mini", "gpt-5.4", minimaxM27Model];
|
||||
export const opencodeNpmPackage = "opencode-ai@1.14.48";
|
||||
export const defaultCodeExecutionMode: CodeExecutionMode = "default";
|
||||
export const codeExecutionModes: CodeExecutionMode[] = ["default", "windows-native"];
|
||||
|
||||
export function normalizeCodeModel(value: string): string {
|
||||
const raw = String(value || "").trim();
|
||||
@@ -43,6 +45,33 @@ export function codeAgentPortForModel(model: string): CodeAgentPortKind {
|
||||
return normalizeCodeModel(model) === minimaxM27Model ? "opencode" : "codex";
|
||||
}
|
||||
|
||||
export function normalizeCodeExecutionMode(value: unknown): CodeExecutionMode {
|
||||
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
if (raw === "windows-native" || raw === "windows" || raw === "win32" || raw === "native-windows") return "windows-native";
|
||||
return defaultCodeExecutionMode;
|
||||
}
|
||||
|
||||
export function codeExecutionModeInfo(mode: CodeExecutionMode): Record<string, JsonValue> {
|
||||
if (mode === "windows-native") {
|
||||
return {
|
||||
kind: mode,
|
||||
label: "Windows native Codex",
|
||||
description: "Run Codex on the provider Windows host while the execution container relays stdio.",
|
||||
codexRunsInContainer: false,
|
||||
requiresProvider: true,
|
||||
requiresWindowsCwd: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: mode,
|
||||
label: "Default Codex runtime",
|
||||
description: "Use the main-server Codex runtime or the provider execution container.",
|
||||
codexRunsInContainer: true,
|
||||
requiresProvider: false,
|
||||
requiresWindowsCwd: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function opencodeModels(models: string[]): string[] {
|
||||
return models.filter((model) => codeAgentPortForModel(model) === "opencode");
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function ensureTaskExecutionContainer(task: QueueTask): Promise<voi
|
||||
const result = await startDevContainerPlan(plan, {
|
||||
forceRecreate: false,
|
||||
verifyPing: false,
|
||||
prepareRuntime: true,
|
||||
prepareRuntime: task.executionMode !== "windows-native",
|
||||
onCommandStart: (command) => {
|
||||
ctx().appendOutput(task, "system", `provider prepare start name=${command.name} target=${command.providerId} timeoutMs=${command.timeoutMs}\n`, "provider/container");
|
||||
},
|
||||
@@ -117,6 +117,7 @@ export async function ensureTaskExecutionContainer(task: QueueTask): Promise<voi
|
||||
taskId: task.id,
|
||||
providerId: plan.providerId,
|
||||
containerName: plan.containerName,
|
||||
executionMode: task.executionMode,
|
||||
workdir: task.cwd,
|
||||
hostWorkdir: plan.workdir,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
defaultCodeModels,
|
||||
extractRecord,
|
||||
extractString,
|
||||
normalizeCodeExecutionMode,
|
||||
normalizeCodeModel,
|
||||
terminalStatus,
|
||||
} from "./code-agent/common";
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
explicitUserInterrupt,
|
||||
judgeFailContinuationPrompt,
|
||||
judgeReasonForPrompt,
|
||||
judgeTaskInputDiagnostics,
|
||||
judgeTask,
|
||||
queueRecoveryRetryPrompt,
|
||||
retryPrompt,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
containerTunnelStartScript,
|
||||
defaultWorkdirForProvider,
|
||||
devContainerPingScript,
|
||||
executionModeOptions,
|
||||
executionProviderOptions,
|
||||
masterKeyReadScript,
|
||||
masterKeySetupScript,
|
||||
@@ -76,6 +79,7 @@ import {
|
||||
runCodeQueueSsh,
|
||||
shellQuote,
|
||||
throwIfCommandFailed,
|
||||
windowsNativeAppServerCommand,
|
||||
} from "./provider-runtime";
|
||||
import {
|
||||
armIdleNotification,
|
||||
@@ -313,7 +317,7 @@ function readConfig(): RuntimeConfig {
|
||||
minimaxApiKey: envString("MINIMAX_API_KEY", ""),
|
||||
minimaxApiBase: envString("MINIMAX_API_BASE", "https://api.minimaxi.com/v1").replace(/\/+$/u, ""),
|
||||
minimaxModel: envString("MINIMAX_MODEL", "MiniMax-M2.7"),
|
||||
judgeTimeoutMs: envNumber("MINIMAX_JUDGE_TIMEOUT_MS", 60_000),
|
||||
judgeTimeoutMs: envNumber("MINIMAX_JUDGE_TIMEOUT_MS", 90_000),
|
||||
judgeRepairAttempts: Math.max(0, Math.min(5, envNumber("MINIMAX_JUDGE_REPAIR_ATTEMPTS", 2))),
|
||||
judgeMaxTokens: Math.max(800, Math.min(4000, envNumber("MINIMAX_JUDGE_MAX_TOKENS", 1800))),
|
||||
turnNoActivityTimeoutMs: Math.max(60_000, Math.min(30 * 60_000, envNumber("CODEX_TURN_NO_ACTIVITY_TIMEOUT_MS", 6 * 60_000))),
|
||||
@@ -337,6 +341,11 @@ function readConfig(): RuntimeConfig {
|
||||
devContainerDefaultProviderId,
|
||||
devContainerImage: envString("CODE_QUEUE_DEV_CONTAINER_IMAGE", ""),
|
||||
devContainerWorkdir: envString("CODE_QUEUE_DEV_CONTAINER_WORKDIR", remoteDefaultWorkdir),
|
||||
windowsNativeCodexBridgeDir: envString("CODE_QUEUE_WINDOWS_NATIVE_CODEX_BRIDGE_DIR", "/home/ubuntu/.unidesk/code-queue/windows-native-codex"),
|
||||
windowsNativeCodexCommand: envString("CODE_QUEUE_WINDOWS_NATIVE_CODEX_COMMAND", "codex app-server --listen stdio://"),
|
||||
windowsNativeCodexConnectHost: envString("CODE_QUEUE_WINDOWS_NATIVE_CODEX_CONNECT_HOST", "host.docker.internal"),
|
||||
windowsNativeCodexDefaultWorkdir: envString("CODE_QUEUE_WINDOWS_NATIVE_CODEX_DEFAULT_WORKDIR", "/mnt/f/Work/ConStart"),
|
||||
windowsNativeCodexIdleTimeoutMs: Math.max(30_000, Math.min(24 * 60 * 60_000, envNumber("CODE_QUEUE_WINDOWS_NATIVE_CODEX_IDLE_TIMEOUT_MS", 10 * 60_000))),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,9 +571,15 @@ function memoryWatchdogThreshold(): number | null {
|
||||
function activeRunMemoryPressure(): (CgroupMemoryUsage & { thresholdBytes: number }) | null {
|
||||
const threshold = memoryWatchdogThreshold();
|
||||
if (threshold === null || threshold <= 0) return null;
|
||||
const usage = readCgroupMemoryUsage();
|
||||
const swapHasHeadroom = usage?.swapMaxBytes === null || (usage !== null && usage.swapMaxBytes > 0 && usage.swapCurrentBytes < Math.floor(usage.swapMaxBytes * 0.9));
|
||||
return usage !== null && usage.workingSetBytes >= threshold && !swapHasHeadroom ? { ...usage, thresholdBytes: threshold } : null;
|
||||
let usage = readCgroupMemoryUsage();
|
||||
if (usage === null || usage.workingSetBytes < threshold) return null;
|
||||
try {
|
||||
(Bun as unknown as { gc?: (force?: boolean) => void }).gc?.(true);
|
||||
usage = readCgroupMemoryUsage();
|
||||
} catch (error) {
|
||||
logger("debug", "memory_watchdog_gc_failed", { error: errorToJson(error) });
|
||||
}
|
||||
return usage !== null && usage.workingSetBytes >= threshold ? { ...usage, thresholdBytes: threshold } : null;
|
||||
}
|
||||
|
||||
function startMemoryWatchdog(): void {
|
||||
@@ -749,6 +764,7 @@ function normalizeTask(task: QueueTask): QueueTask {
|
||||
task.activeTurnId ??= null;
|
||||
task.providerId = normalizeTaskProviderId(task.providerId);
|
||||
task.model ||= config.defaultModel;
|
||||
task.executionMode = normalizeCodeExecutionMode(task.executionMode);
|
||||
task.cwd = resolveTaskCwd(task.providerId, task.cwd);
|
||||
task.reasoningEffort = resolveReasoningEffort(task.model, task.reasoningEffort);
|
||||
task.basePrompt ||= userPromptForDisplay(task.prompt);
|
||||
@@ -816,6 +832,7 @@ function isOpenCodeStepBoundaryMethod(method: string | undefined): boolean {
|
||||
|
||||
function outputCanChangeStepCount(output: LiveOutput): boolean {
|
||||
if (output.channel === "user" && output.method === "enqueue") return false;
|
||||
if (output.channel === "system") return false;
|
||||
return !isOpenCodeStepBoundaryMethod(output.method);
|
||||
}
|
||||
|
||||
@@ -827,7 +844,8 @@ function commandStartedBeforeIn(outputs: LiveOutput[], output: LiveOutput): bool
|
||||
function outputStartsTraceStepInHistory(outputs: LiveOutput[], output: LiveOutput): boolean {
|
||||
if (output.channel === "user" && output.method === "enqueue") return false;
|
||||
if (isOpenCodeStepBoundaryMethod(output.method)) return false;
|
||||
if (output.channel === "diff" || output.channel === "tool" || output.channel === "error" || output.channel === "assistant" || output.channel === "reasoning" || output.channel === "system") return true;
|
||||
if (output.channel === "system") return false;
|
||||
if (output.channel === "diff" || output.channel === "tool" || output.channel === "error" || output.channel === "assistant" || output.channel === "reasoning") return true;
|
||||
if (output.channel === "user") return true;
|
||||
if (output.channel !== "command") return true;
|
||||
const method = String(output.method || "");
|
||||
@@ -900,6 +918,24 @@ function errorToJson(error: unknown): JsonValue {
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function judgeFailureDetailsForOutput(judge: JudgeResult): string {
|
||||
const details = judge.failureDetails;
|
||||
if (details === undefined || details === null) return "";
|
||||
return [
|
||||
"",
|
||||
`MiniMax failure details: stage=${details.stage}`,
|
||||
`timedOut=${details.timedOut}`,
|
||||
`durationMs=${details.durationMs}`,
|
||||
`timeoutMs=${details.timeoutMs}`,
|
||||
details.promptChars === undefined ? "" : `promptChars=${details.promptChars}`,
|
||||
details.promptLines === undefined ? "" : `promptLines=${details.promptLines}`,
|
||||
details.payloadBytes === undefined ? "" : `payloadBytes=${details.payloadBytes}`,
|
||||
details.responseStatus === undefined || details.responseStatus === null ? "" : `responseStatus=${details.responseStatus}`,
|
||||
`errorName=${details.errorName}`,
|
||||
`error=${safePreview(details.errorMessage, 220)}`,
|
||||
].filter((part) => part.length > 0).join(" ");
|
||||
}
|
||||
|
||||
function databaseErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
@@ -966,6 +1002,7 @@ async function upsertTaskToDatabase(client: SqlExecutor, task: QueueTask): Promi
|
||||
queue_id,
|
||||
status,
|
||||
provider_id,
|
||||
execution_mode,
|
||||
model,
|
||||
cwd,
|
||||
prompt,
|
||||
@@ -995,6 +1032,7 @@ async function upsertTaskToDatabase(client: SqlExecutor, task: QueueTask): Promi
|
||||
${queueIdOf(task)},
|
||||
${task.status},
|
||||
${task.providerId},
|
||||
${task.executionMode},
|
||||
${task.model},
|
||||
${task.cwd},
|
||||
${task.prompt},
|
||||
@@ -1024,6 +1062,7 @@ async function upsertTaskToDatabase(client: SqlExecutor, task: QueueTask): Promi
|
||||
status = EXCLUDED.status,
|
||||
queue_id = EXCLUDED.queue_id,
|
||||
provider_id = EXCLUDED.provider_id,
|
||||
execution_mode = EXCLUDED.execution_mode,
|
||||
model = EXCLUDED.model,
|
||||
cwd = EXCLUDED.cwd,
|
||||
prompt = EXCLUDED.prompt,
|
||||
@@ -1352,6 +1391,7 @@ async function initDatabasePersistence(): Promise<void> {
|
||||
queue_id TEXT NOT NULL DEFAULT 'default',
|
||||
status TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL DEFAULT 'main-server',
|
||||
execution_mode TEXT NOT NULL DEFAULT 'default',
|
||||
model TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
prompt TEXT NOT NULL,
|
||||
@@ -1403,6 +1443,7 @@ async function initDatabasePersistence(): Promise<void> {
|
||||
`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS queue_id TEXT NOT NULL DEFAULT 'default'`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS provider_id TEXT NOT NULL DEFAULT 'main-server'`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS execution_mode TEXT NOT NULL DEFAULT 'default'`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS base_prompt TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS reference_task_ids JSONB NOT NULL DEFAULT '[]'::jsonb`;
|
||||
await sql`ALTER TABLE unidesk_code_queue_tasks ADD COLUMN IF NOT EXISTS reference_injection JSONB`;
|
||||
@@ -1419,6 +1460,7 @@ async function initDatabasePersistence(): Promise<void> {
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_status_updated ON unidesk_code_queue_tasks(status, updated_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_queue_status_updated ON unidesk_code_queue_tasks(queue_id, status, updated_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_provider_updated ON unidesk_code_queue_tasks(provider_id, updated_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_execution_mode_updated ON unidesk_code_queue_tasks(execution_mode, updated_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_created ON unidesk_code_queue_tasks(created_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_queue_created ON unidesk_code_queue_tasks(queue_id, created_at DESC, id DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_unidesk_code_queue_tasks_unread_terminal ON unidesk_code_queue_tasks(queue_id, updated_at DESC) WHERE read_at IS NULL AND status IN ('succeeded', 'failed', 'canceled')`;
|
||||
@@ -1669,18 +1711,29 @@ function normalizeRequest(value: unknown): QueueTaskRequest {
|
||||
if (typeof record.cwd === "string" && record.cwd.length > 0) request.cwd = record.cwd;
|
||||
if (typeof record.model === "string" && record.model.length > 0) request.model = record.model;
|
||||
if (typeof record.reasoningEffort === "string" && record.reasoningEffort.length > 0) request.reasoningEffort = record.reasoningEffort;
|
||||
if (typeof record.executionMode === "string" && record.executionMode.length > 0) request.executionMode = normalizeCodeExecutionMode(record.executionMode);
|
||||
if (typeof record.maxAttempts === "number" && Number.isInteger(record.maxAttempts) && record.maxAttempts > 0) request.maxAttempts = Math.min(maxTaskAttempts, record.maxAttempts);
|
||||
const referenceTaskIds = collectReferenceTaskIds(record, record.prompt);
|
||||
if (referenceTaskIds.length > 0) request.referenceTaskIds = referenceTaskIds;
|
||||
return request;
|
||||
}
|
||||
|
||||
function validateExecutionModeForTask(providerId: string, cwd: string, model: string, executionMode: ReturnType<typeof normalizeCodeExecutionMode>): void {
|
||||
if (executionMode !== "windows-native") return;
|
||||
if (providerIsMain(providerId)) throw new Error("windows-native executionMode requires a non-main WSL provider");
|
||||
if (codeAgentPortForModel(model) !== "codex") throw new Error("windows-native executionMode only supports Codex models");
|
||||
if (!cwd.startsWith("/mnt/")) throw new Error("windows-native executionMode requires cwd under /mnt/<drive>");
|
||||
}
|
||||
|
||||
function createTask(request: QueueTaskRequest): QueueTask {
|
||||
const at = nowIso();
|
||||
const basePrompt = request.basePrompt ?? userPromptForDisplay(request.prompt);
|
||||
const referenceTaskIds = request.referenceTaskIds ?? [];
|
||||
const providerId = normalizeTaskProviderId(request.providerId);
|
||||
const model = normalizeCodeModel(request.model ?? config.defaultModel);
|
||||
const executionMode = normalizeCodeExecutionMode(request.executionMode);
|
||||
const cwd = resolveTaskCwd(providerId, request.cwd);
|
||||
validateExecutionModeForTask(providerId, cwd, model, executionMode);
|
||||
const queueId = normalizeQueueId(request.queueId);
|
||||
ensureQueue(queueId);
|
||||
return {
|
||||
@@ -1692,9 +1745,10 @@ function createTask(request: QueueTaskRequest): QueueTask {
|
||||
referenceTaskIds,
|
||||
referenceInjection: request.referenceInjection ?? null,
|
||||
providerId,
|
||||
cwd: resolveTaskCwd(providerId, request.cwd),
|
||||
cwd,
|
||||
model,
|
||||
reasoningEffort: resolveReasoningEffort(model, request.reasoningEffort),
|
||||
executionMode,
|
||||
maxAttempts: request.maxAttempts ?? config.defaultMaxAttempts,
|
||||
status: "queued",
|
||||
createdAt: at,
|
||||
@@ -2067,6 +2121,7 @@ configureCodexPort({
|
||||
recordNumberField,
|
||||
recordStringField,
|
||||
remoteAppServerCommand,
|
||||
windowsNativeAppServerCommand,
|
||||
resolveReasoningEffort,
|
||||
safePreview,
|
||||
nowIso,
|
||||
@@ -2149,6 +2204,7 @@ function taskForJudgeProbe(probe: JudgeProbeCase): QueueTask {
|
||||
cwd: config.defaultWorkdir,
|
||||
model: config.defaultModel,
|
||||
reasoningEffort: resolveReasoningEffort(config.defaultModel, config.defaultReasoningEffort),
|
||||
executionMode: "default",
|
||||
maxAttempts: 3,
|
||||
status: "judging",
|
||||
createdAt: at,
|
||||
@@ -2239,6 +2295,231 @@ async function runJudgeProbe(): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function maxOutputSeqBefore(output: LiveOutput[], exclusiveSeq: number): number | null {
|
||||
let maxSeq: number | null = null;
|
||||
for (const item of output) {
|
||||
const seq = numberOrNull(item.seq);
|
||||
if (seq === null || seq >= exclusiveSeq) continue;
|
||||
maxSeq = maxSeq === null ? seq : Math.max(maxSeq, seq);
|
||||
}
|
||||
return maxSeq;
|
||||
}
|
||||
|
||||
function outputSeqAtOrBeforeTime(output: LiveOutput[], at: string | null): number | null {
|
||||
const atMs = timestampMs(at);
|
||||
if (atMs === null) return null;
|
||||
let maxSeq: number | null = null;
|
||||
for (const item of output) {
|
||||
const itemMs = timestampMs(item.at);
|
||||
if (itemMs === null || itemMs > atMs) continue;
|
||||
const seq = numberOrNull(item.seq);
|
||||
if (seq === null) continue;
|
||||
maxSeq = maxSeq === null ? seq : Math.max(maxSeq, seq);
|
||||
}
|
||||
return maxSeq;
|
||||
}
|
||||
|
||||
function preJudgeOutputEndSeq(attempt: AttemptSummary, output: LiveOutput[]): { endSeq: number | null; source: string } {
|
||||
const judgeSeq = numberOrNull(attempt.judgeSeq);
|
||||
if (judgeSeq !== null) return { endSeq: maxOutputSeqBefore(output, judgeSeq), source: "before-judgeSeq" };
|
||||
const explicitEnd = numberOrNull(attempt.outputEndSeq);
|
||||
if (explicitEnd !== null) {
|
||||
const explicitItem = output.find((item) => numberOrNull(item.seq) === explicitEnd);
|
||||
if (explicitItem?.method === "judge") return { endSeq: maxOutputSeqBefore(output, explicitEnd), source: "before-outputEndSeq-judge" };
|
||||
return { endSeq: explicitEnd, source: "outputEndSeq" };
|
||||
}
|
||||
const byFinishedAt = outputSeqAtOrBeforeTime(output, attempt.finishedAt);
|
||||
return { endSeq: byFinishedAt, source: byFinishedAt === null ? "unbounded" : "finishedAt" };
|
||||
}
|
||||
|
||||
function outputBeforeJudge(output: LiveOutput[], endSeq: number | null): LiveOutput[] {
|
||||
if (endSeq === null) return output;
|
||||
return output.filter((item) => numberOrNull(item.seq) !== null && Number(item.seq) <= endSeq);
|
||||
}
|
||||
|
||||
function currentAttemptOutputCountForReplay(attempt: AttemptSummary, output: LiveOutput[], hotOutput: LiveOutput[], endSeq: number | null): number {
|
||||
const startSeq = numberOrNull(attempt.outputStartSeq);
|
||||
if (startSeq === null) return hotOutput.slice(-80).length;
|
||||
return output.filter((item) => {
|
||||
const seq = numberOrNull(item.seq);
|
||||
return seq !== null && seq >= startSeq && (endSeq === null || seq <= endSeq);
|
||||
}).length;
|
||||
}
|
||||
|
||||
function finalResponseForTaskBeforeJudge(attempts: AttemptSummary[]): string {
|
||||
for (let index = attempts.length - 1; index >= 0; index -= 1) {
|
||||
const finalResponse = String(attempts[index]?.finalResponse ?? "");
|
||||
if (finalResponse.trim().length > 0) return finalResponse;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function retainedEventsForAttempt(task: QueueTask, attempt: AttemptSummary): CodexRunResult["events"] {
|
||||
const startedMs = timestampMs(attempt.startedAt);
|
||||
const finishedMs = timestampMs(attempt.finishedAt);
|
||||
return task.events.filter((event) => {
|
||||
const eventMs = timestampMs(event.at);
|
||||
if (eventMs === null) return false;
|
||||
if (startedMs !== null && eventMs < startedMs) return false;
|
||||
if (finishedMs !== null && eventMs > finishedMs + 1000) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function attemptEventsForReplay(task: QueueTask, attempt: AttemptSummary): { events: CodexRunResult["events"]; source: string; exact: boolean } {
|
||||
if (Array.isArray(attempt.events)) return { events: cloneJson(attempt.events), source: "attempt.events", exact: true };
|
||||
return { events: cloneJson(retainedEventsForAttempt(task, attempt)), source: "retained-task-events", exact: false };
|
||||
}
|
||||
|
||||
function replayAttemptIndexFromUrl(task: QueueTask, url: URL): number {
|
||||
const raw = url.searchParams.get("attempt") ?? url.searchParams.get("attemptId") ?? url.searchParams.get("attemptIndex");
|
||||
if (raw !== null) {
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) throw new Error("judge replay attempt must be a positive integer");
|
||||
return value;
|
||||
}
|
||||
const latest = task.attempts.at(-1)?.index ?? task.currentAttempt;
|
||||
if (!Number.isInteger(latest) || latest <= 0) throw new Error("task has no completed attempt to judge");
|
||||
return latest;
|
||||
}
|
||||
|
||||
function buildJudgeReplay(task: QueueTask, attemptIndex: number): {
|
||||
task: QueueTask;
|
||||
result: CodexRunResult;
|
||||
attempt: AttemptSummary;
|
||||
replay: Record<string, JsonValue>;
|
||||
} {
|
||||
const attempt = task.attempts.find((item) => item.index === attemptIndex);
|
||||
if (attempt === undefined) throw new Error(`attempt ${attemptIndex} not found on task ${task.id}`);
|
||||
const fullOutput = taskFullOutput(task);
|
||||
const preJudge = preJudgeOutputEndSeq(attempt, fullOutput);
|
||||
const beforeJudge = outputBeforeJudge(fullOutput, preJudge.endSeq);
|
||||
const hotOutput = config.maxInMemoryOutputRecords > 0 ? beforeJudge.slice(-config.maxInMemoryOutputRecords) : beforeJudge;
|
||||
const attempts = task.attempts
|
||||
.filter((item) => item.index <= attempt.index)
|
||||
.map((item) => cloneJson(item));
|
||||
const latestAttempt = attempts[attempts.length - 1];
|
||||
if (latestAttempt === undefined) throw new Error(`attempt ${attemptIndex} could not be prepared`);
|
||||
latestAttempt.judge = null;
|
||||
latestAttempt.judgeAt = null;
|
||||
latestAttempt.judgeSeq = null;
|
||||
latestAttempt.outputEndSeq = preJudge.endSeq;
|
||||
const eventReplay = attemptEventsForReplay(task, attempt);
|
||||
const previousAttempts = attempts.filter((item) => item.index < attempt.index);
|
||||
const result: CodexRunResult = {
|
||||
threadId: task.codexThreadId,
|
||||
turnId: null,
|
||||
finalResponse: String(attempt.finalResponse ?? ""),
|
||||
terminalStatus: attempt.terminalStatus,
|
||||
terminalError: attempt.error,
|
||||
transportClosedBeforeTerminal: attempt.transportClosedBeforeTerminal,
|
||||
appServerExit: {
|
||||
code: attempt.appServerExitCode,
|
||||
signal: attempt.appServerSignal,
|
||||
stderrTail: String(attempt.stderrTail ?? ""),
|
||||
},
|
||||
events: eventReplay.events,
|
||||
};
|
||||
const replayTask: QueueTask = {
|
||||
...cloneJson(task),
|
||||
status: "judging",
|
||||
currentAttempt: attempt.index,
|
||||
currentMode: attempt.mode,
|
||||
attempts,
|
||||
output: numberOrNull(attempt.outputStartSeq) === null ? hotOutput : beforeJudge,
|
||||
events: eventReplay.events,
|
||||
finalResponse: finalResponseForTaskBeforeJudge(attempts),
|
||||
lastJudge: previousAttempts.at(-1)?.judge ?? null,
|
||||
judgeFailCount: previousAttempts.filter((item) => item.judge?.decision === "fail").length,
|
||||
activeTurnId: null,
|
||||
finishedAt: null,
|
||||
readAt: null,
|
||||
nextPrompt: null,
|
||||
nextMode: null,
|
||||
};
|
||||
return {
|
||||
task: replayTask,
|
||||
result,
|
||||
attempt,
|
||||
replay: {
|
||||
sameJudgeCodePath: true,
|
||||
replayExact: eventReplay.exact,
|
||||
taskId: task.id,
|
||||
attemptIndex: attempt.index,
|
||||
attemptMode: attempt.mode,
|
||||
outputStartSeq: attempt.outputStartSeq ?? null,
|
||||
storedOutputEndSeq: attempt.outputEndSeq ?? null,
|
||||
replayOutputEndSeq: preJudge.endSeq,
|
||||
replayOutputEndSeqSource: preJudge.source,
|
||||
judgeSeq: attempt.judgeSeq ?? null,
|
||||
fullOutputBeforeJudgeCount: beforeJudge.length,
|
||||
hotOutputCount: hotOutput.length,
|
||||
currentAttemptOutputCount: currentAttemptOutputCountForReplay(attempt, beforeJudge, hotOutput, preJudge.endSeq),
|
||||
eventSource: eventReplay.source,
|
||||
eventReplayExact: eventReplay.exact,
|
||||
eventCount: eventReplay.events.length,
|
||||
note: eventReplay.exact
|
||||
? "Replay uses attempt-stored event summaries plus the same judgeTask context builder and MiniMax call path."
|
||||
: "Historical attempt did not store per-attempt events; replay falls back to retained task.events. Output, final response, terminal status, stderr, attempt window, prompt compaction, and MiniMax call path are reconstructed from persisted task/output archives.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runSingleTaskJudge(task: QueueTask, url: URL): Promise<Response> {
|
||||
const attemptIndex = replayAttemptIndexFromUrl(task, url);
|
||||
const dryRun = truthyParam(url, "dryRun") || truthyParam(url, "noCall");
|
||||
const includePrompt = truthyParam(url, "includePrompt");
|
||||
const replay = buildJudgeReplay(task, attemptIndex);
|
||||
const context = judgeTaskInputDiagnostics(replay.task, replay.result, includePrompt);
|
||||
const startedAt = Date.now();
|
||||
const judge = dryRun ? null : await judgeTask(replay.task, replay.result);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
const storedJudge = replay.attempt.judge ?? null;
|
||||
const storedFailure = storedJudge?.failureDetails ?? null;
|
||||
const promptChars = Number(context.promptChars);
|
||||
const payloadBytes = Number(context.payloadBytes);
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
dryRun,
|
||||
configured: config.minimaxApiKey.length > 0,
|
||||
model: config.minimaxModel,
|
||||
taskId: task.id,
|
||||
attempt: {
|
||||
index: replay.attempt.index,
|
||||
mode: replay.attempt.mode,
|
||||
terminalStatus: replay.attempt.terminalStatus,
|
||||
finalResponseChars: replay.attempt.finalResponseChars ?? String(replay.attempt.finalResponse ?? "").length,
|
||||
startedAt: replay.attempt.startedAt,
|
||||
finishedAt: replay.attempt.finishedAt,
|
||||
},
|
||||
replay: replay.replay,
|
||||
context,
|
||||
judge,
|
||||
durationMs,
|
||||
storedJudge,
|
||||
comparison: {
|
||||
storedDecision: storedJudge?.decision ?? null,
|
||||
storedSource: storedJudge?.source ?? null,
|
||||
decisionMatchesStored: judge === null || storedJudge === null ? null : judge.decision === storedJudge.decision,
|
||||
storedFailureTimedOut: storedFailure?.timedOut ?? null,
|
||||
storedFailureStage: storedFailure?.stage ?? null,
|
||||
storedPromptChars: storedFailure?.promptChars ?? null,
|
||||
promptCharsDeltaFromStored: storedFailure?.promptChars === undefined || !Number.isFinite(promptChars) ? null : promptChars - storedFailure.promptChars,
|
||||
storedPayloadBytes: storedFailure?.payloadBytes ?? null,
|
||||
payloadBytesDeltaFromStored: storedFailure?.payloadBytes === undefined || !Number.isFinite(payloadBytes) ? null : payloadBytes - storedFailure.payloadBytes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function attemptFromResult(task: QueueTask, mode: RunMode, startedAt: string, finishedAt: string, result: CodexRunResult, outputStartSeq: number | null = null, outputEndSeq: number | null = null, inputPrompt: string | null = null): AttemptSummary {
|
||||
const finalResponse = result.finalResponse;
|
||||
const attempt: AttemptSummary = {
|
||||
@@ -2246,11 +2527,14 @@ function attemptFromResult(task: QueueTask, mode: RunMode, startedAt: string, fi
|
||||
mode,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
terminalStatus: result.terminalStatus,
|
||||
transportClosedBeforeTerminal: result.transportClosedBeforeTerminal,
|
||||
appServerExitCode: result.appServerExit.code,
|
||||
appServerSignal: result.appServerExit.signal,
|
||||
error: result.terminalError,
|
||||
events: result.events,
|
||||
finalResponse,
|
||||
finalResponsePreview: safePreview(finalResponse, 3000),
|
||||
finalResponseChars: finalResponse.length,
|
||||
@@ -2323,7 +2607,7 @@ function failTaskForFallbackRetryLimit(task: QueueTask, judge: JudgeResult | nul
|
||||
}
|
||||
|
||||
async function runTask(task: QueueTask): Promise<void> {
|
||||
logger("info", "task_processor_start", { taskId: task.id, queueId: queueIdOf(task), providerId: task.providerId, cwd: task.cwd, maxAttempts: task.maxAttempts, model: task.model, agentPort: codeAgentPortForModel(task.model), promptPreview: safePreview(task.prompt, 240) });
|
||||
logger("info", "task_processor_start", { taskId: task.id, queueId: queueIdOf(task), providerId: task.providerId, executionMode: task.executionMode, cwd: task.cwd, maxAttempts: task.maxAttempts, model: task.model, agentPort: codeAgentPortForModel(task.model), promptPreview: safePreview(task.prompt, 240) });
|
||||
if (task.status === "retry_wait" && task.lastJudge?.source === "fallback" && task.lastJudge.decision === "retry" && fallbackJudgeRetryCount(task) >= fallbackJudgeRetryLimit) {
|
||||
failTaskForFallbackRetryLimit(task, task.lastJudge);
|
||||
return;
|
||||
@@ -2354,8 +2638,8 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
task.readAt = null;
|
||||
task.finishedAt = null;
|
||||
task.updatedAt = startedAt;
|
||||
logger("info", "task_run_start", { taskId: task.id, queueId: queueIdOf(task), attempt: task.currentAttempt, mode, providerId: task.providerId, cwd: task.cwd, maxAttempts: task.maxAttempts, model: task.model, agentPort: codeAgentPortForModel(task.model), freshRecovery: needsFreshRecoveryPrompt });
|
||||
const attemptStartOutput = appendOutput(task, "system", `attempt ${task.currentAttempt}/${task.maxAttempts} queue=${queueIdOf(task)} provider=${task.providerId} cwd=${task.cwd} mode=${mode} model=${task.model} port=${codeAgentPortForModel(task.model)}\n`, "queue");
|
||||
logger("info", "task_run_start", { taskId: task.id, queueId: queueIdOf(task), attempt: task.currentAttempt, mode, providerId: task.providerId, executionMode: task.executionMode, cwd: task.cwd, maxAttempts: task.maxAttempts, model: task.model, agentPort: codeAgentPortForModel(task.model), freshRecovery: needsFreshRecoveryPrompt });
|
||||
const attemptStartOutput = appendOutput(task, "system", `attempt ${task.currentAttempt}/${task.maxAttempts} queue=${queueIdOf(task)} provider=${task.providerId} executionMode=${task.executionMode} cwd=${task.cwd} mode=${mode} model=${task.model} port=${codeAgentPortForModel(task.model)}\n`, "queue");
|
||||
|
||||
let result: CodexRunResult;
|
||||
try {
|
||||
@@ -2374,7 +2658,7 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
if (task.cancelRequested) break;
|
||||
const judge = await judgeTask(task, result);
|
||||
task.lastJudge = judge;
|
||||
const judgeOutput = appendOutput(task, judge.decision === "complete" ? "system" : judge.decision === "fail" ? "error" : "system", `judge=${judge.decision} confidence=${judge.confidence.toFixed(2)} source=${judge.source}: ${judge.reason}\n`, "judge");
|
||||
const judgeOutput = appendOutput(task, judge.decision === "complete" ? "system" : judge.decision === "fail" ? "error" : "system", `judge=${judge.decision} confidence=${judge.confidence.toFixed(2)} source=${judge.source}: ${judge.reason}${judgeFailureDetailsForOutput(judge)}\n`, "judge");
|
||||
const latestAttempt = task.attempts.at(-1);
|
||||
if (latestAttempt !== undefined && latestAttempt.index === task.currentAttempt) {
|
||||
latestAttempt.judge = judge;
|
||||
@@ -2382,7 +2666,7 @@ async function runTask(task: QueueTask): Promise<void> {
|
||||
latestAttempt.judgeSeq = judgeOutput?.seq ?? null;
|
||||
latestAttempt.outputEndSeq = judgeOutput?.seq ?? latestAttempt.outputEndSeq ?? null;
|
||||
}
|
||||
logger("info", "task_judged", { taskId: task.id, attempt: task.currentAttempt, decision: judge.decision, confidence: judge.confidence, source: judge.source, reason: safePreview(judge.reason, 500) });
|
||||
logger("info", "task_judged", { taskId: task.id, attempt: task.currentAttempt, decision: judge.decision, confidence: judge.confidence, source: judge.source, reason: safePreview(judge.reason, 500), failureDetails: judge.failureDetails ?? null } as unknown as JsonValue);
|
||||
|
||||
if (judge.decision === "complete") {
|
||||
task.status = "succeeded";
|
||||
@@ -2856,6 +3140,7 @@ function requestErrorResponse(error: unknown): Response | null {
|
||||
|| error.message.startsWith("referenceTaskIds supports at most ")
|
||||
|| error.message.startsWith("queueId must match ")
|
||||
|| error.message.startsWith("queue name must be ")
|
||||
|| error.message.startsWith("windows-native executionMode ")
|
||||
)) {
|
||||
return jsonResponse({ ok: false, error: error.message }, 400);
|
||||
}
|
||||
@@ -2889,7 +3174,7 @@ async function createTasks(req: Request): Promise<Response> {
|
||||
for (const task of tasks) publishTaskOaEvent(task, "enqueue");
|
||||
for (const id of new Set(tasks.map(queueIdOf))) publishQueueEvent("enqueue", id);
|
||||
persistState();
|
||||
logger("info", "tasks_enqueued", { count: tasks.length, ids: tasks.map((task) => task.id), queueIds: Array.from(new Set(tasks.map(queueIdOf))), providerIds: Array.from(new Set(tasks.map((task) => task.providerId))) });
|
||||
logger("info", "tasks_enqueued", { count: tasks.length, ids: tasks.map((task) => task.id), queueIds: Array.from(new Set(tasks.map(queueIdOf))), providerIds: Array.from(new Set(tasks.map((task) => task.providerId))), executionModes: Array.from(new Set(tasks.map((task) => task.executionMode))) });
|
||||
scheduleQueue();
|
||||
await flushDirtyTasksToDatabase(true);
|
||||
return jsonResponse({ ok: true, tasks: tasks.map((task) => taskForResponse(task)), queue: await queueSummaryForResponse() }, 202);
|
||||
@@ -3402,7 +3687,7 @@ async function route(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
if (req.method === "OPTIONS") return jsonResponse({ ok: true });
|
||||
try {
|
||||
if (url.pathname === "/" || url.pathname === "/health") return jsonResponse({ ok: true, service: "code-queue", queue: await queueSummaryForHealth(), startedAt: serviceStartedAt });
|
||||
if (url.pathname === "/" || url.pathname === "/health") return jsonResponse({ ok: true, service: "code-queue", queue: await queueSummaryForHealth(false), startedAt: serviceStartedAt });
|
||||
if (url.pathname === "/logs") return jsonResponse({ ok: true, logs: recentLogs.slice(-parseLimit(url)) });
|
||||
if (url.pathname === "/api/events" && req.method === "GET") return jsonResponse({ ok: false, error: "Code Queue private SSE was removed; subscribe to oa-event-flow /api/events/stream with service:code-queue tags." }, 410);
|
||||
if (url.pathname === "/api/dev-ready" && req.method === "GET") return jsonResponse({ ok: true, devReady: collectDevReady() });
|
||||
@@ -3546,6 +3831,12 @@ async function route(req: Request): Promise<Response> {
|
||||
if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404);
|
||||
return await taskTraceStepDetailResponse(task, url);
|
||||
}
|
||||
const judgeTaskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/judge$/u);
|
||||
if (judgeTaskMatch !== null && (req.method === "GET" || req.method === "POST")) {
|
||||
const task = await findTaskForRead(decodeURIComponent(judgeTaskMatch[1] ?? ""));
|
||||
if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404);
|
||||
return await runSingleTaskJudge(task, url);
|
||||
}
|
||||
const summaryMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/summary$/u);
|
||||
if (summaryMatch !== null && req.method === "GET") {
|
||||
const task = await findTaskForRead(decodeURIComponent(summaryMatch[1] ?? ""));
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CodexRunResult,
|
||||
FeedbackPromptRecord,
|
||||
JudgeDecision,
|
||||
JudgeFailureDetails,
|
||||
JudgeProbeCase,
|
||||
JudgeResult,
|
||||
JsonValue,
|
||||
@@ -80,7 +81,13 @@ function promptLineCount(text: string): number {
|
||||
return ctx().promptLineCount(text);
|
||||
}
|
||||
|
||||
function fallbackJudge(result: CodexRunResult, minimaxError?: string): JudgeResult {
|
||||
function failureDetailReason(detail: JudgeFailureDetails): string {
|
||||
const timeout = detail.timedOut ? `timed out after ${detail.timeoutMs}ms` : detail.errorMessage;
|
||||
const promptSize = detail.promptChars === undefined ? "" : `,prompt=${detail.promptChars} chars/${detail.promptLines ?? "?"} lines`;
|
||||
return `MiniMax judge ${timeout}${promptSize}`;
|
||||
}
|
||||
|
||||
function fallbackJudge(result: CodexRunResult, minimaxError?: string | JudgeFailureDetails): JudgeResult {
|
||||
if (result.transportClosedBeforeTerminal || result.terminalStatus === null) {
|
||||
return { decision: "retry", confidence: 0.75, reason: "Codex app-server 在 turn/completed 之前关闭。", continuePrompt: retryInstruction, source: "fallback" };
|
||||
}
|
||||
@@ -94,11 +101,17 @@ function fallbackJudge(result: CodexRunResult, minimaxError?: string): JudgeResu
|
||||
return { decision: "retry", confidence: 0.78, reason: "Codex turn 没有返回最终 assistant response,不能视为任务已完成。", continuePrompt: retryInstruction, source: "fallback" };
|
||||
}
|
||||
if (minimaxError !== undefined) {
|
||||
const failureDetails = typeof minimaxError === "string" ? null : minimaxError;
|
||||
const errorText = typeof minimaxError === "string" ? minimaxError : failureDetailReason(minimaxError);
|
||||
return {
|
||||
decision: "retry",
|
||||
confidence: 0.65,
|
||||
reason: `MiniMax judge 失败(${safePreview(minimaxError, 240)});安全 fallback 将继续现有 session,而不是把 turn/completed 当作任务已完成。`,
|
||||
reason: `MiniMax judge 失败(${safePreview(errorText, 300)});安全 fallback 将继续现有 session,而不是把 turn/completed 当作任务已完成。`,
|
||||
source: "fallback",
|
||||
failureDetails,
|
||||
raw: failureDetails === null
|
||||
? { minimaxFailure: { errorMessage: String(minimaxError) } }
|
||||
: { minimaxFailure: failureDetails as unknown as JsonValue },
|
||||
};
|
||||
}
|
||||
return { decision: "complete", confidence: 0.65, reason: "Codex 输出了 completed 状态的 turn/completed,且未配置 MiniMax judge。", source: "fallback" };
|
||||
@@ -135,21 +148,99 @@ function judgeEvidenceText(task: QueueTask, result: CodexRunResult): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function encodedBytes(value: string): number {
|
||||
return new TextEncoder().encode(value).length;
|
||||
}
|
||||
|
||||
function compactJudgeOutputItems(output: LiveOutput[]): { items: Array<{ channel: string; text: string; method?: string; seq?: number; at?: string }>; omitted: number; totalTextChars: number } {
|
||||
const important = output.filter((item) => {
|
||||
const method = String(item.method || "");
|
||||
const text = String(item.text || "");
|
||||
return item.channel === "error"
|
||||
|| method === "turn/completed"
|
||||
|| method === "item/completed"
|
||||
|| method === "item/started"
|
||||
|| method === "provider/container"
|
||||
|| method === "execution/mode"
|
||||
|| method.includes("watchdog")
|
||||
|| /(error|failed|失败|timeout|timed out|aborted|rebuild|health|succeeded|verified|passed|judge)/iu.test(text);
|
||||
});
|
||||
const bySeq = new Map<number, LiveOutput>();
|
||||
for (const item of [...important.slice(-32), ...output.slice(-28)]) {
|
||||
const seq = Number(item.seq);
|
||||
if (Number.isFinite(seq)) bySeq.set(seq, item);
|
||||
}
|
||||
const selected = Array.from(bySeq.values()).sort((left, right) => Number(left.seq) - Number(right.seq));
|
||||
const items: Array<{ channel: string; text: string; method?: string; seq?: number; at?: string }> = [];
|
||||
let remainingChars = 14_000;
|
||||
let totalTextChars = 0;
|
||||
for (const item of selected) {
|
||||
const rawText = String(item.text || "");
|
||||
totalTextChars += rawText.length;
|
||||
if (remainingChars <= 0) break;
|
||||
const perItem = item.channel === "error" ? 700 : 360;
|
||||
const max = Math.max(120, Math.min(perItem, remainingChars));
|
||||
const text = safePreview(rawText, max);
|
||||
remainingChars -= text.length;
|
||||
items.push({
|
||||
channel: item.channel,
|
||||
method: item.method,
|
||||
seq: item.seq,
|
||||
at: item.at,
|
||||
text,
|
||||
});
|
||||
}
|
||||
return { items, omitted: Math.max(0, output.length - items.length), totalTextChars };
|
||||
}
|
||||
|
||||
function compactJudgeEvents(events: CodexRunResult["events"]): { events: CodexRunResult["events"]; omitted: number } {
|
||||
const selected = events.slice(-30).map((event) => ({
|
||||
...event,
|
||||
message: event.message === undefined ? undefined : safePreview(event.message, 300),
|
||||
textPreview: event.textPreview === undefined ? undefined : safePreview(event.textPreview, 300),
|
||||
}));
|
||||
return { events: selected, omitted: Math.max(0, events.length - selected.length) };
|
||||
}
|
||||
|
||||
function hasServiceLimitOrTransportError(text: string): boolean {
|
||||
return /(429|Too Many Requests|rate limit|quota exceeded|overloaded|exceeded retry limit|stream disconnected|no activity timeout|app-server closed|ECONNRESET|ETIMEDOUT)/iu.test(text);
|
||||
}
|
||||
|
||||
function compactLatestAttemptForJudge(attempt: QueueTask["attempts"][number] | null): Record<string, unknown> | null {
|
||||
if (attempt === null) return null;
|
||||
return {
|
||||
index: attempt.index,
|
||||
mode: attempt.mode,
|
||||
startedAt: attempt.startedAt,
|
||||
finishedAt: attempt.finishedAt,
|
||||
providerId: attempt.providerId,
|
||||
executionMode: attempt.executionMode,
|
||||
terminalStatus: attempt.terminalStatus,
|
||||
transportClosedBeforeTerminal: attempt.transportClosedBeforeTerminal,
|
||||
appServerExitCode: attempt.appServerExitCode,
|
||||
appServerSignal: attempt.appServerSignal,
|
||||
error: attempt.error,
|
||||
finalResponsePreview: safePreview(String(attempt.finalResponsePreview || attempt.finalResponse || ""), 1200),
|
||||
finalResponseChars: Number(attempt.finalResponseChars ?? String(attempt.finalResponse || "").length),
|
||||
stderrTail: safePreview(String(attempt.stderrTail || ""), 1200),
|
||||
outputStartSeq: attempt.outputStartSeq ?? null,
|
||||
outputEndSeq: attempt.outputEndSeq ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function judgePrompt(task: QueueTask, result: CodexRunResult): string {
|
||||
const latestAttempt = task.attempts[task.attempts.length - 1] ?? null;
|
||||
const originalUserTask = task.basePrompt || userPromptForDisplay(task.prompt);
|
||||
const resolvedPromptForCodex = task.prompt === originalUserTask ? null : safePreview(task.prompt, 12000);
|
||||
const resolvedPromptForCodex = task.prompt === originalUserTask ? null : safePreview(task.prompt, 6000);
|
||||
const currentAttemptOutput = currentAttemptOutputForJudge(task);
|
||||
const outputEvidence = compactJudgeOutputItems(currentAttemptOutput);
|
||||
const eventEvidence = compactJudgeEvents(result.events);
|
||||
// Keep this record factual. Do not add local completion gates here; MiniMax
|
||||
// is authoritative whenever it returns a valid judge JSON.
|
||||
return JSON.stringify({
|
||||
instruction: "请判定一个 Codex 编码任务是否真正完成、是否应通过向现有 Codex thread 追加 continuation prompt 继续重试,或是否应作为不可重试失败处理。只能返回 JSON,不要输出 Markdown fence、解释性正文或注释。所有自然语言字段必须使用中文,尤其是 reason 和 continuePrompt。重要:普通的 Codex turn/completed 状态只表示传输/session 终止事件,不等于用户任务已完成。决策前必须检查当前尝试的 transcript、最终回复、命令/文件变更事件、stderr 和原始任务。请严格判定:如果用户任务中的任一显式验收项缺少已完成证据,就选择 retry。最常见错误是把未完成或跑偏的工作标成 fail;未完成工作必须选择 retry,让同一个 session 继续。不要把更早 attempt 的限流/中断证据自动当作当前 attempt 的完成门禁;如果当前最新 attempt 已经提供完整完成证据,可以判定 complete。",
|
||||
schema: { decision: "complete|retry|fail", confidence: "0..1", reason: "中文短句", continuePrompt: "decision=retry 时必填,除非确实没有可用的继续提示;内容必须是中文,保持简洁,不要粘贴原始任务、引用上下文、transcript 或 JSON" },
|
||||
originalTask: originalUserTask,
|
||||
originalTask: safePreview(originalUserTask, 6000),
|
||||
resolvedPromptForCodex,
|
||||
attempt: task.currentAttempt,
|
||||
maxAttempts: task.maxAttempts,
|
||||
@@ -163,12 +254,15 @@ function judgePrompt(task: QueueTask, result: CodexRunResult): string {
|
||||
finalResponse: safePreview(result.finalResponse, 6000),
|
||||
finalResponseChars: result.finalResponse.length,
|
||||
finalResponseMissing: result.finalResponse.trim().length === 0,
|
||||
latestAttempt,
|
||||
latestAttempt: compactLatestAttemptForJudge(latestAttempt),
|
||||
judgeFailCount: task.judgeFailCount,
|
||||
judgeFailRetryLimit: ctx().judgeFailRetryLimit,
|
||||
cancelRequested: task.cancelRequested,
|
||||
currentAttemptOutput: currentAttemptOutput.slice(-80).map((item) => ({ channel: item.channel, text: safePreview(item.text, 500), method: item.method })),
|
||||
currentAttemptEvents: result.events.slice(-60),
|
||||
currentAttemptOutput: outputEvidence.items,
|
||||
currentAttemptOutputOmitted: outputEvidence.omitted,
|
||||
currentAttemptOutputTotalTextChars: outputEvidence.totalTextChars,
|
||||
currentAttemptEvents: eventEvidence.events,
|
||||
currentAttemptEventsOmitted: eventEvidence.omitted,
|
||||
},
|
||||
policy: {
|
||||
complete: "仅当 transcript/最终回复证明当前任务确实完成,并且每个显式验收项都有证据时使用;队列 worker 随后会推进到下一个 queued 任务。",
|
||||
@@ -513,6 +607,102 @@ export function miniMaxJudgeMessages(userContent: string, repair: JudgeRepairCon
|
||||
return messages;
|
||||
}
|
||||
|
||||
class MiniMaxJudgeFailure extends Error {
|
||||
readonly details: JudgeFailureDetails;
|
||||
|
||||
constructor(details: JudgeFailureDetails) {
|
||||
super(details.errorMessage);
|
||||
this.name = "MiniMaxJudgeFailure";
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string {
|
||||
return error instanceof Error ? error.name || "Error" : typeof error;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function minimaxFailureDetails(input: {
|
||||
stage: JudgeFailureDetails["stage"];
|
||||
error: unknown;
|
||||
startedAt: number;
|
||||
finishedAt?: number;
|
||||
timedOut?: boolean;
|
||||
repairAttempt?: number;
|
||||
maxRepairAttempts?: number;
|
||||
promptChars?: number;
|
||||
promptLines?: number;
|
||||
payloadBytes?: number;
|
||||
responseStatus?: number | null;
|
||||
responseText?: string;
|
||||
responseContent?: string;
|
||||
}): JudgeFailureDetails {
|
||||
const finishedAt = input.finishedAt ?? Date.now();
|
||||
return {
|
||||
provider: "minimax",
|
||||
stage: input.stage,
|
||||
model: config().minimaxModel,
|
||||
apiBase: config().minimaxApiBase,
|
||||
occurredAt: new Date(finishedAt).toISOString(),
|
||||
durationMs: Math.max(0, finishedAt - input.startedAt),
|
||||
timeoutMs: config().judgeTimeoutMs,
|
||||
timedOut: input.timedOut === true,
|
||||
errorName: errorName(input.error),
|
||||
errorMessage: errorMessage(input.error),
|
||||
repairAttempt: input.repairAttempt,
|
||||
maxRepairAttempts: input.maxRepairAttempts,
|
||||
promptChars: input.promptChars,
|
||||
promptLines: input.promptLines,
|
||||
payloadBytes: input.payloadBytes,
|
||||
responseStatus: input.responseStatus,
|
||||
responseTextPreview: input.responseText === undefined ? undefined : safePreview(input.responseText, 1000),
|
||||
responseTextChars: input.responseText?.length,
|
||||
responseContentPreview: input.responseContent === undefined ? undefined : safePreview(input.responseContent, 1000),
|
||||
responseContentChars: input.responseContent?.length,
|
||||
};
|
||||
}
|
||||
|
||||
function messagePromptStats(messages: Array<{ role: string; content: string }>): { promptChars: number; promptLines: number } {
|
||||
return messages.reduce((stats, message) => {
|
||||
const content = String(message.content || "");
|
||||
stats.promptChars += content.length;
|
||||
stats.promptLines += promptLineCount(content);
|
||||
return stats;
|
||||
}, { promptChars: 0, promptLines: 0 });
|
||||
}
|
||||
|
||||
export function judgeTaskInputDiagnostics(task: QueueTask, result: CodexRunResult, includePrompt = false): Record<string, JsonValue> {
|
||||
const judgePromptContent = judgePrompt(task, result);
|
||||
const messages = miniMaxJudgeMessages(judgePromptContent, null);
|
||||
const promptStats = messagePromptStats(messages);
|
||||
const body = JSON.stringify({
|
||||
model: config().minimaxModel,
|
||||
temperature: 0,
|
||||
max_tokens: config().judgeMaxTokens,
|
||||
messages,
|
||||
});
|
||||
return {
|
||||
provider: "minimax",
|
||||
model: config().minimaxModel,
|
||||
apiBase: config().minimaxApiBase,
|
||||
configured: config().minimaxApiKey.length > 0,
|
||||
timeoutMs: config().judgeTimeoutMs,
|
||||
repairAttempts: config().judgeRepairAttempts,
|
||||
maxTokens: config().judgeMaxTokens,
|
||||
messageCount: messages.length,
|
||||
judgePromptChars: judgePromptContent.length,
|
||||
judgePromptLines: promptLineCount(judgePromptContent),
|
||||
promptChars: promptStats.promptChars,
|
||||
promptLines: promptStats.promptLines,
|
||||
payloadBytes: encodedBytes(body),
|
||||
promptPreview: safePreview(judgePromptContent, 1200),
|
||||
...(includePrompt ? { prompt: judgePromptContent } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function judgeScopeText(task: QueueTask, result: CodexRunResult): string {
|
||||
return [
|
||||
task.basePrompt,
|
||||
@@ -673,12 +863,13 @@ function applyJudgeSafetyOverrides(task: QueueTask, result: CodexRunResult, judg
|
||||
export async function judgeTask(task: QueueTask, result: CodexRunResult): Promise<JudgeResult> {
|
||||
if (config().minimaxApiKey.length === 0) return applyJudgeSafetyOverrides(task, result, fallbackJudge(result));
|
||||
const judgePromptContent = judgePrompt(task, result);
|
||||
const basePromptStats = { promptChars: judgePromptContent.length, promptLines: promptLineCount(judgePromptContent) };
|
||||
try {
|
||||
let lastParseError: string | null = null;
|
||||
let repairContext: JudgeRepairContext | null = null;
|
||||
for (let repairAttempt = 0; repairAttempt <= config().judgeRepairAttempts; repairAttempt += 1) {
|
||||
const messages = miniMaxJudgeMessages(judgePromptContent, repairContext);
|
||||
const response = await requestMiniMaxJudge(messages);
|
||||
const response = await requestMiniMaxJudge(messages, repairAttempt);
|
||||
const preDenoiseContent = response.content;
|
||||
try {
|
||||
const parsedResult = parseJudgeJson(preDenoiseContent);
|
||||
@@ -695,12 +886,32 @@ export async function judgeTask(task: QueueTask, result: CodexRunResult): Promis
|
||||
reason: typeof parsed.reason === "string" ? parsed.reason : "MiniMax judge returned a decision.",
|
||||
continuePrompt,
|
||||
source: "minimax",
|
||||
raw: { ...(parsed as Record<string, JsonValue>), _parseSource: parsedResult.source, _repairAttempt: repairAttempt },
|
||||
raw: {
|
||||
...(parsed as Record<string, JsonValue>),
|
||||
_parseSource: parsedResult.source,
|
||||
_repairAttempt: repairAttempt,
|
||||
_request: response.diagnostics as unknown as JsonValue,
|
||||
},
|
||||
};
|
||||
return applyJudgeSafetyOverrides(task, result, judge);
|
||||
} catch (error) {
|
||||
lastParseError = error instanceof Error ? error.message : String(error);
|
||||
if (repairAttempt >= config().judgeRepairAttempts) throw new Error(lastParseError);
|
||||
if (repairAttempt >= config().judgeRepairAttempts) {
|
||||
throw new MiniMaxJudgeFailure(minimaxFailureDetails({
|
||||
stage: /continuePrompt exceeds source budget/iu.test(lastParseError) ? "validation" : "parse",
|
||||
error: new Error(lastParseError),
|
||||
startedAt: Date.now() - response.diagnostics.durationMs,
|
||||
finishedAt: Date.now(),
|
||||
repairAttempt,
|
||||
maxRepairAttempts: config().judgeRepairAttempts,
|
||||
promptChars: response.diagnostics.promptChars,
|
||||
promptLines: response.diagnostics.promptLines,
|
||||
payloadBytes: response.diagnostics.payloadBytes,
|
||||
responseStatus: response.diagnostics.responseStatus,
|
||||
responseText: response.rawText,
|
||||
responseContent: preDenoiseContent,
|
||||
}));
|
||||
}
|
||||
logger("warn", "judge_json_parse_retry", {
|
||||
taskId: task.id,
|
||||
repairAttempt: repairAttempt + 1,
|
||||
@@ -713,29 +924,99 @@ export async function judgeTask(task: QueueTask, result: CodexRunResult): Promis
|
||||
}
|
||||
throw new Error(lastParseError ?? "MiniMax judge exhausted JSON repair attempts");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger("warn", "judge_failed_fallback", { taskId: task.id, error: message });
|
||||
return applyJudgeSafetyOverrides(task, result, fallbackJudge(result, message));
|
||||
const detail = error instanceof MiniMaxJudgeFailure
|
||||
? error.details
|
||||
: minimaxFailureDetails({ stage: "unknown", error, startedAt: Date.now(), promptChars: basePromptStats.promptChars, promptLines: basePromptStats.promptLines });
|
||||
logger("warn", "judge_failed_fallback", {
|
||||
taskId: task.id,
|
||||
error: detail.errorMessage,
|
||||
errorName: detail.errorName,
|
||||
stage: detail.stage,
|
||||
timedOut: detail.timedOut,
|
||||
durationMs: detail.durationMs,
|
||||
timeoutMs: detail.timeoutMs,
|
||||
promptChars: detail.promptChars,
|
||||
promptLines: detail.promptLines,
|
||||
payloadBytes: detail.payloadBytes,
|
||||
responseStatus: detail.responseStatus,
|
||||
repairAttempt: detail.repairAttempt,
|
||||
maxRepairAttempts: detail.maxRepairAttempts,
|
||||
} as unknown as JsonValue);
|
||||
return applyJudgeSafetyOverrides(task, result, fallbackJudge(result, detail));
|
||||
}
|
||||
}
|
||||
|
||||
async function requestMiniMaxJudge(messages: Array<{ role: "system" | "user" | "assistant"; content: string }>): Promise<MiniMaxJudgeResponse> {
|
||||
async function requestMiniMaxJudge(messages: Array<{ role: "system" | "user" | "assistant"; content: string }>, repairAttempt: number): Promise<MiniMaxJudgeResponse> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), config().judgeTimeoutMs);
|
||||
let timedOut = false;
|
||||
const startedAt = Date.now();
|
||||
const promptStats = messagePromptStats(messages);
|
||||
const body = JSON.stringify({
|
||||
model: config().minimaxModel,
|
||||
temperature: 0,
|
||||
max_tokens: config().judgeMaxTokens,
|
||||
messages,
|
||||
});
|
||||
const payloadBytes = encodedBytes(body);
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, config().judgeTimeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${config().minimaxApiBase}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${config().minimaxApiKey}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: config().minimaxModel,
|
||||
temperature: 0,
|
||||
max_tokens: config().judgeMaxTokens,
|
||||
messages,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rawText = await response.text();
|
||||
if (!response.ok) throw new Error(`MiniMax HTTP ${response.status}: ${safePreview(rawText, 1000)}`);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${config().minimaxApiBase}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${config().minimaxApiKey}`, "content-type": "application/json" },
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new MiniMaxJudgeFailure(minimaxFailureDetails({
|
||||
stage: "request",
|
||||
error,
|
||||
startedAt,
|
||||
timedOut,
|
||||
repairAttempt,
|
||||
maxRepairAttempts: config().judgeRepairAttempts,
|
||||
promptChars: promptStats.promptChars,
|
||||
promptLines: promptStats.promptLines,
|
||||
payloadBytes,
|
||||
}));
|
||||
}
|
||||
let rawText: string;
|
||||
try {
|
||||
rawText = await response.text();
|
||||
} catch (error) {
|
||||
throw new MiniMaxJudgeFailure(minimaxFailureDetails({
|
||||
stage: "request",
|
||||
error,
|
||||
startedAt,
|
||||
timedOut,
|
||||
repairAttempt,
|
||||
maxRepairAttempts: config().judgeRepairAttempts,
|
||||
promptChars: promptStats.promptChars,
|
||||
promptLines: promptStats.promptLines,
|
||||
payloadBytes,
|
||||
responseStatus: response.status,
|
||||
}));
|
||||
}
|
||||
const finishedAt = Date.now();
|
||||
if (!response.ok) {
|
||||
throw new MiniMaxJudgeFailure(minimaxFailureDetails({
|
||||
stage: "http",
|
||||
error: new Error(`MiniMax HTTP ${response.status}: ${safePreview(rawText, 1000)}`),
|
||||
startedAt,
|
||||
finishedAt,
|
||||
repairAttempt,
|
||||
maxRepairAttempts: config().judgeRepairAttempts,
|
||||
promptChars: promptStats.promptChars,
|
||||
promptLines: promptStats.promptLines,
|
||||
payloadBytes,
|
||||
responseStatus: response.status,
|
||||
responseText: rawText,
|
||||
}));
|
||||
}
|
||||
let content = rawText;
|
||||
try {
|
||||
const payload = JSON.parse(rawText) as Record<string, unknown>;
|
||||
@@ -749,7 +1030,23 @@ async function requestMiniMaxJudge(messages: Array<{ role: "system" | "user" | "
|
||||
} catch {
|
||||
content = rawText;
|
||||
}
|
||||
return { rawText, content };
|
||||
return {
|
||||
rawText,
|
||||
content,
|
||||
diagnostics: {
|
||||
provider: "minimax",
|
||||
model: config().minimaxModel,
|
||||
apiBase: config().minimaxApiBase,
|
||||
durationMs: finishedAt - startedAt,
|
||||
timeoutMs: config().judgeTimeoutMs,
|
||||
promptChars: promptStats.promptChars,
|
||||
promptLines: promptStats.promptLines,
|
||||
payloadBytes,
|
||||
responseStatus: response.status,
|
||||
responseTextChars: rawText.length,
|
||||
responseContentChars: content.length,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ function taskStatusPayload(task: QueueTask, queueId: string, reason: string, ste
|
||||
finishedAt: task.finishedAt,
|
||||
readAt: task.readAt,
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
model: task.model,
|
||||
currentAttempt: task.currentAttempt,
|
||||
stepCount,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { opencodeNpmPackage } from "./code-agent/common";
|
||||
import { codeExecutionModeInfo, opencodeNpmPackage } from "./code-agent/common";
|
||||
import type { DevContainerCommandLog, DevContainerPlan, JsonValue, QueueTask, RuntimeConfig } from "./types";
|
||||
|
||||
export interface ProviderRuntimeContext {
|
||||
config: Pick<RuntimeConfig,
|
||||
"codexHome" | "defaultWorkdir" | "devContainerDefaultProviderId" | "devContainerImage" | "devContainerMasterHost" | "devContainerWorkdir" | "executionProviderIds" | "mainProviderId" | "remoteCodexEnvKeys" | "remoteDefaultWorkdir" | "sourceCodexConfig"
|
||||
"codexHome" | "defaultWorkdir" | "devContainerDefaultProviderId" | "devContainerImage" | "devContainerMasterHost" | "devContainerWorkdir" | "executionProviderIds" | "mainProviderId" | "remoteCodexEnvKeys" | "remoteDefaultWorkdir" | "sourceCodexConfig" | "windowsNativeCodexBridgeDir" | "windowsNativeCodexCommand" | "windowsNativeCodexConnectHost" | "windowsNativeCodexDefaultWorkdir" | "windowsNativeCodexIdleTimeoutMs"
|
||||
>;
|
||||
safePreview: (value: string, max?: number) => string;
|
||||
}
|
||||
@@ -73,10 +73,29 @@ function executionProviderOptions(): JsonValue[] {
|
||||
label: providerIsMain(providerId) ? `${providerId} (master)` : providerId,
|
||||
kind: providerIsMain(providerId) ? "local" : "remote-dev-container",
|
||||
defaultWorkdir: defaultWorkdirForProvider(providerId),
|
||||
supportsWindowsNativeCodex: !providerIsMain(providerId),
|
||||
windowsNativeDefaultWorkdir: providerIsMain(providerId) ? null : ctx().config.windowsNativeCodexDefaultWorkdir,
|
||||
containerName: providerIsMain(providerId) ? null : buildDevContainerPlan(providerId, {}).containerName,
|
||||
}));
|
||||
}
|
||||
|
||||
function executionModeOptions(): JsonValue[] {
|
||||
return [
|
||||
{
|
||||
...codeExecutionModeInfo("default"),
|
||||
id: "default",
|
||||
defaultWorkdir: null,
|
||||
},
|
||||
{
|
||||
...codeExecutionModeInfo("windows-native"),
|
||||
id: "windows-native",
|
||||
defaultWorkdir: ctx().config.windowsNativeCodexDefaultWorkdir,
|
||||
bridgeDir: ctx().config.windowsNativeCodexBridgeDir,
|
||||
connectHost: ctx().config.windowsNativeCodexConnectHost,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function numericIdFromProvider(providerId: string): number {
|
||||
const digits = providerId.match(/\d+/u)?.[0] ?? "";
|
||||
if (digits.length > 0) {
|
||||
@@ -457,6 +476,321 @@ CONTAINER=${shellQuote(plan.containerName)}
|
||||
docker exec "$CONTAINER" bash -lc 'export PATH=/tmp/unidesk-tools:$PATH; echo route=$(ip route show default | head -1); echo resolv=$(tr "\\n" " " </etc/resolv.conf); ping -c 1 -W 5 google.com'`;
|
||||
}
|
||||
|
||||
function windowsNativeBridgeServerSource(): string {
|
||||
return String.raw`#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def write_text(path, text):
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="UniDesk Windows native Codex stdio bridge")
|
||||
parser.add_argument("--session", required=True)
|
||||
parser.add_argument("--cwd", required=True)
|
||||
parser.add_argument("--codex-command", required=True)
|
||||
parser.add_argument("--bind-host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
parser.add_argument("--port-file", required=True)
|
||||
parser.add_argument("--pid-file", required=True)
|
||||
parser.add_argument("--idle-timeout", type=float, default=600)
|
||||
parser.add_argument("--initial-connect-timeout", type=float, default=90)
|
||||
parser.add_argument("--stdout-log", required=True)
|
||||
parser.add_argument("--stderr-log", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.cwd.startswith("/mnt/"):
|
||||
raise SystemExit("windows-native Codex cwd must be under /mnt/<drive>")
|
||||
if not os.path.isdir(args.cwd):
|
||||
raise SystemExit("windows-native Codex cwd does not exist: " + args.cwd)
|
||||
|
||||
env = os.environ.copy()
|
||||
home = os.path.expanduser("~")
|
||||
env["PATH"] = os.path.join(home, ".local", "bin") + os.pathsep + env.get("PATH", "")
|
||||
env["CODEX_INTERNAL_ORIGINATOR_OVERRIDE"] = "unidesk_code_queue_windows_native"
|
||||
bridge_env_keys = [
|
||||
"OPENAI_API_KEY",
|
||||
"CRS_OAI_KEY",
|
||||
"OPENAI_BASE_URL",
|
||||
"OPENAI_API_BASE",
|
||||
"MINIMAX_API_KEY",
|
||||
"MINIMAX_API_BASE",
|
||||
"MINIMAX_MODEL",
|
||||
"CODEX_INTERNAL_ORIGINATOR_OVERRIDE",
|
||||
]
|
||||
existing_wsl_env = [item for item in env.get("WSLENV", "").split(":") if item]
|
||||
existing_wsl_names = {item.split("/", 1)[0] for item in existing_wsl_env}
|
||||
for key in bridge_env_keys:
|
||||
if key in env and key not in existing_wsl_names:
|
||||
existing_wsl_env.append(key)
|
||||
env["WSLENV"] = ":".join(existing_wsl_env)
|
||||
|
||||
child = subprocess.Popen(
|
||||
["win-cmd", args.codex_command],
|
||||
cwd=args.cwd,
|
||||
env=env,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
)
|
||||
|
||||
clients = set()
|
||||
lock = threading.Lock()
|
||||
last_empty_at = time.time()
|
||||
ever_connected = False
|
||||
stop_event = threading.Event()
|
||||
|
||||
def log(message):
|
||||
print(message, file=sys.stderr, flush=True)
|
||||
|
||||
def close_client(conn):
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
with lock:
|
||||
clients.discard(conn)
|
||||
|
||||
def broadcast(data):
|
||||
dead = []
|
||||
with lock:
|
||||
snapshot = list(clients)
|
||||
for conn in snapshot:
|
||||
try:
|
||||
conn.sendall(data)
|
||||
except OSError:
|
||||
dead.append(conn)
|
||||
for conn in dead:
|
||||
close_client(conn)
|
||||
|
||||
def pump_stdout():
|
||||
with open(args.stdout_log, "ab", buffering=0) as log_file:
|
||||
while True:
|
||||
data = child.stdout.readline() if child.stdout is not None else b""
|
||||
if not data:
|
||||
break
|
||||
log_file.write(data)
|
||||
broadcast(data)
|
||||
stop_event.set()
|
||||
with lock:
|
||||
snapshot = list(clients)
|
||||
for conn in snapshot:
|
||||
close_client(conn)
|
||||
|
||||
def pump_stderr():
|
||||
with open(args.stderr_log, "ab", buffering=0) as log_file:
|
||||
while True:
|
||||
data = child.stderr.readline() if child.stderr is not None else b""
|
||||
if not data:
|
||||
break
|
||||
log_file.write(data)
|
||||
|
||||
threading.Thread(target=pump_stdout, daemon=True).start()
|
||||
threading.Thread(target=pump_stderr, daemon=True).start()
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind((args.bind_host, args.port))
|
||||
server.listen(8)
|
||||
server.settimeout(1)
|
||||
port = server.getsockname()[1]
|
||||
os.makedirs(os.path.dirname(args.port_file), exist_ok=True)
|
||||
write_text(args.port_file, str(port))
|
||||
write_text(args.pid_file, str(os.getpid()))
|
||||
log("windows_native_codex_bridge_ready session=%s pid=%s port=%s child=%s cwd=%s" % (args.session, os.getpid(), port, child.pid, args.cwd))
|
||||
|
||||
def handle_client(conn):
|
||||
nonlocal last_empty_at
|
||||
try:
|
||||
while True:
|
||||
data = conn.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
if child.stdin is not None:
|
||||
child.stdin.write(data)
|
||||
child.stdin.flush()
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
finally:
|
||||
close_client(conn)
|
||||
with lock:
|
||||
if len(clients) == 0:
|
||||
last_empty_at = time.time()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
if child.poll() is not None:
|
||||
break
|
||||
with lock:
|
||||
empty_for = time.time() - last_empty_at if len(clients) == 0 else 0
|
||||
no_client_yet = not ever_connected and len(clients) == 0
|
||||
if no_client_yet and empty_for >= args.initial_connect_timeout:
|
||||
log("windows_native_codex_bridge_initial_connect_timeout session=%s seconds=%.1f" % (args.session, empty_for))
|
||||
break
|
||||
if not no_client_yet and empty_for >= args.idle_timeout:
|
||||
log("windows_native_codex_bridge_idle_timeout session=%s seconds=%.1f" % (args.session, empty_for))
|
||||
break
|
||||
try:
|
||||
conn, _addr = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with lock:
|
||||
clients.add(conn)
|
||||
ever_connected = True
|
||||
threading.Thread(target=handle_client, args=(conn,), daemon=True).start()
|
||||
finally:
|
||||
try:
|
||||
server.close()
|
||||
except OSError:
|
||||
pass
|
||||
if child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
try:
|
||||
os.remove(args.port_file)
|
||||
except OSError:
|
||||
pass
|
||||
log("windows_native_codex_bridge_exit session=%s childExit=%s" % (args.session, child.poll()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
`;
|
||||
}
|
||||
|
||||
function windowsNativeContainerClientSource(): string {
|
||||
return String.raw`const fs = require("node:fs");
|
||||
const dns = require("node:dns");
|
||||
const net = require("node:net");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const host = process.env.UNIDESK_WINDOWS_NATIVE_CODEX_HOST || "host.docker.internal";
|
||||
const port = Number(process.env.UNIDESK_WINDOWS_NATIVE_CODEX_PORT);
|
||||
const extraHosts = String(process.env.UNIDESK_WINDOWS_NATIVE_CODEX_EXTRA_HOSTS || "").split(/[,\s]+/u).filter(Boolean);
|
||||
const connectDeadlineMs = Math.max(5000, Number(process.env.UNIDESK_WINDOWS_NATIVE_CODEX_CONNECT_TIMEOUT_MS || 60000));
|
||||
if (!Number.isInteger(port) || port <= 0) throw new Error("UNIDESK_WINDOWS_NATIVE_CODEX_PORT is invalid");
|
||||
|
||||
function gatewayFromHex(value) {
|
||||
const bytes = value.match(/../g);
|
||||
return bytes ? bytes.reverse().map((item) => Number.parseInt(item, 16)).join(".") : "";
|
||||
}
|
||||
|
||||
function routeGateways() {
|
||||
try {
|
||||
return fs.readFileSync("/proc/net/route", "utf8").trim().split(/\n/u).slice(1).flatMap((line) => {
|
||||
const parts = line.trim().split(/\s+/u);
|
||||
if (parts.length < 3 || parts[0].startsWith("tun") || parts[2] === "00000000") return [];
|
||||
const gateway = gatewayFromHex(parts[2]);
|
||||
return gateway.length > 0 ? [{ iface: parts[0], gateway }] : [];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function lookup4(name) {
|
||||
return new Promise((resolve) => {
|
||||
dns.lookup(name, { family: 4, all: true }, (error, addresses) => {
|
||||
resolve(error ? [] : [...new Set(addresses.map((item) => item.address))]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureHostRoutes(ips) {
|
||||
const ipBin = fs.existsSync("/tmp/unidesk-tools/ip") ? "/tmp/unidesk-tools/ip" : "ip";
|
||||
for (const { iface, gateway } of routeGateways()) {
|
||||
for (const ip of ips) {
|
||||
spawnSync(ipBin, ["route", "replace", ip + "/32", "via", gateway, "dev", iface], { stdio: "ignore" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectOnce(candidate) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: candidate, port });
|
||||
const timer = setTimeout(() => socket.destroy(new Error("connect timeout")), 2500);
|
||||
socket.once("connect", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(socket);
|
||||
});
|
||||
socket.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const hostIps = await lookup4(host);
|
||||
ensureHostRoutes(hostIps);
|
||||
const candidates = [...new Set([...hostIps, host, ...extraHosts, ...routeGateways().map((item) => item.gateway), "172.17.0.1"].filter(Boolean))];
|
||||
const errors = [];
|
||||
const deadline = Date.now() + connectDeadlineMs;
|
||||
let attempt = 0;
|
||||
while (Date.now() < deadline) {
|
||||
attempt += 1;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await connectOnce(candidate);
|
||||
} catch (error) {
|
||||
errors.push(candidate + "=" + (error && error.message ? error.message : String(error)));
|
||||
while (errors.length > 24) errors.shift();
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error("could not connect to Windows native Codex bridge after " + attempt + " attempts in " + connectDeadlineMs + "ms: " + errors.join("; "));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const socket = await connect();
|
||||
socket.setNoDelay(true);
|
||||
socket.on("data", (chunk) => {
|
||||
if (!process.stdout.write(chunk)) socket.pause();
|
||||
});
|
||||
process.stdout.on("drain", () => socket.resume());
|
||||
socket.on("end", () => process.exit(0));
|
||||
socket.on("close", () => process.exit(0));
|
||||
socket.on("error", (error) => {
|
||||
console.error(error && error.stack ? error.stack : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
process.stdin.on("data", (chunk) => {
|
||||
if (!socket.write(chunk)) process.stdin.pause();
|
||||
});
|
||||
socket.on("drain", () => process.stdin.resume());
|
||||
process.stdin.on("end", () => socket.end());
|
||||
})().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : String(error));
|
||||
process.exit(1);
|
||||
});`;
|
||||
}
|
||||
|
||||
function windowsNativeBridgeInstallScript(): string {
|
||||
const bridgeBase64 = base64Text(windowsNativeBridgeServerSource());
|
||||
return `mkdir -p ${shellQuote(ctx().config.windowsNativeCodexBridgeDir)}
|
||||
printf %s ${shellQuote(bridgeBase64)} | base64 -d > ${shellQuote(resolve(ctx().config.windowsNativeCodexBridgeDir, "bridge.py"))}
|
||||
chmod 700 ${shellQuote(resolve(ctx().config.windowsNativeCodexBridgeDir, "bridge.py"))}`;
|
||||
}
|
||||
|
||||
function remoteCodexRuntimePrepareScript(plan: DevContainerPlan): string {
|
||||
return `set -euo pipefail
|
||||
CONTAINER=${shellQuote(plan.containerName)}
|
||||
@@ -500,12 +834,56 @@ function remoteAppServerCommand(task: QueueTask): string {
|
||||
return `docker exec -i ${shellQuote(plan.containerName)} bash -lc ${shellQuote(inner)}`;
|
||||
}
|
||||
|
||||
function windowsNativeSessionName(task: QueueTask): string {
|
||||
return safeDockerName(`${task.id}-attempt-${task.currentAttempt || 0}`).slice(0, 120);
|
||||
}
|
||||
|
||||
function windowsNativeAppServerCommand(task: QueueTask): string {
|
||||
const plan = buildDevContainerPlan(task.providerId, { workdir: remoteHostWorkdirForTask(task) });
|
||||
const session = windowsNativeSessionName(task);
|
||||
const sessionDir = resolve(ctx().config.windowsNativeCodexBridgeDir, "sessions", session);
|
||||
const portFile = resolve(sessionDir, "port");
|
||||
const pidFile = resolve(sessionDir, "pid");
|
||||
const stdoutLog = resolve(sessionDir, "codex.stdout.jsonl");
|
||||
const stderrLog = resolve(sessionDir, "codex.stderr.log");
|
||||
const serverLog = resolve(sessionDir, "bridge.log");
|
||||
const bridgePy = resolve(ctx().config.windowsNativeCodexBridgeDir, "bridge.py");
|
||||
const clientSource = windowsNativeContainerClientSource();
|
||||
const install = windowsNativeBridgeInstallScript();
|
||||
const idleSeconds = Math.max(30, Math.floor(ctx().config.windowsNativeCodexIdleTimeoutMs / 1000));
|
||||
const initialConnectSeconds = Math.max(45, Math.min(120, idleSeconds));
|
||||
return [
|
||||
"set -euo pipefail",
|
||||
`export PATH="$HOME/.local/bin:$PATH"`,
|
||||
`CWD=${shellQuote(task.cwd)}`,
|
||||
`case "$CWD" in /mnt/*) ;; *) echo "windows-native executionMode requires cwd under /mnt/<drive>, got $CWD" >&2; exit 64 ;; esac`,
|
||||
`test -d "$CWD" || { echo "windows-native cwd does not exist: $CWD" >&2; exit 64; }`,
|
||||
install,
|
||||
`SESSION_DIR=${shellQuote(sessionDir)}`,
|
||||
`PORT_FILE=${shellQuote(portFile)}`,
|
||||
`PID_FILE=${shellQuote(pidFile)}`,
|
||||
`KEY_DIR=${shellQuote(plan.keyDir)}`,
|
||||
`[ ! -r "$KEY_DIR/codex-env" ] || { set -a; . "$KEY_DIR/codex-env"; set +a; }`,
|
||||
`mkdir -p "$SESSION_DIR"`,
|
||||
`if [ -s "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" >/dev/null 2>&1 && [ -s "$PORT_FILE" ]; then :; else rm -f "$PORT_FILE" "$PID_FILE"; nohup python3 ${shellQuote(bridgePy)} --session ${shellQuote(session)} --cwd "$CWD" --codex-command ${shellQuote(ctx().config.windowsNativeCodexCommand)} --bind-host 0.0.0.0 --port 0 --port-file "$PORT_FILE" --pid-file "$PID_FILE" --idle-timeout ${shellQuote(String(idleSeconds))} --initial-connect-timeout ${shellQuote(String(initialConnectSeconds))} --stdout-log ${shellQuote(stdoutLog)} --stderr-log ${shellQuote(stderrLog)} > ${shellQuote(serverLog)} 2>&1 & fi`,
|
||||
`for i in $(seq 1 120); do [ -s "$PORT_FILE" ] && break; sleep 0.25; done`,
|
||||
`if [ ! -s "$PORT_FILE" ]; then echo "windows-native Codex bridge did not become ready; log follows:" >&2; tail -80 ${shellQuote(serverLog)} >&2 2>/dev/null || true; exit 65; fi`,
|
||||
`PORT=$(cat "$PORT_FILE")`,
|
||||
`WSL_HOSTS=$(hostname -I 2>/dev/null | tr ' ' ',' | sed 's/,,*/,/g; s/^,//; s/,$//')`,
|
||||
`echo "windows_native_codex_stdio provider=${task.providerId} session=${session} container=${plan.containerName} port=$PORT cwd=$CWD" >&2`,
|
||||
`CLIENT_SOURCE=${shellQuote(clientSource)}`,
|
||||
`CLIENT_CMD='if command -v node >/dev/null 2>&1; then exec node -e "$UNIDESK_WINDOWS_NATIVE_CODEX_CLIENT_SOURCE"; fi; if command -v bun >/dev/null 2>&1; then exec bun -e "$UNIDESK_WINDOWS_NATIVE_CODEX_CLIENT_SOURCE"; fi; echo "windows-native relay container requires node or bun" >&2; exit 127'`,
|
||||
`docker exec -i -e UNIDESK_WINDOWS_NATIVE_CODEX_HOST=${shellQuote(ctx().config.windowsNativeCodexConnectHost)} -e UNIDESK_WINDOWS_NATIVE_CODEX_PORT="$PORT" -e UNIDESK_WINDOWS_NATIVE_CODEX_EXTRA_HOSTS="$WSL_HOSTS" -e UNIDESK_WINDOWS_NATIVE_CODEX_CONNECT_TIMEOUT_MS=60000 -e UNIDESK_WINDOWS_NATIVE_CODEX_CLIENT_SOURCE="$CLIENT_SOURCE" ${shellQuote(plan.containerName)} bash -lc "$CLIENT_CMD"`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
buildDevContainerPlan,
|
||||
containerTunnelStartScript,
|
||||
defaultWorkdirForProvider,
|
||||
devContainerPingScript,
|
||||
executionModeOptions,
|
||||
executionProviderOptions,
|
||||
masterKeyReadScript,
|
||||
masterKeySetupScript,
|
||||
@@ -525,4 +903,5 @@ export {
|
||||
runCodeQueueSsh,
|
||||
shellQuote,
|
||||
throwIfCommandFailed,
|
||||
windowsNativeAppServerCommand,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 重构前 index.ts 只读参考:commit 6a04144d3f5103014f75b637d7e6bc2f45bf007f,blob 56e590c1a6b5ca7ad128bf2c992f60e46c355a58;可用 `git show 6a04144d3f5103014f75b637d7e6bc2f45bf007f:src/components/microservices/code-queue/src/index.ts` 查看。
|
||||
|
||||
import postgres from "postgres";
|
||||
import { codeAgentPortForModel, codeAgentPortInfo, codeModelPorts as codeModelPortsFor, opencodeModels as opencodeModelsFor } from "./code-agent/common";
|
||||
import { codeAgentPortForModel, codeAgentPortInfo, codeExecutionModeInfo, codeExecutionModes, codeModelPorts as codeModelPortsFor, opencodeModels as opencodeModelsFor } from "./code-agent/common";
|
||||
import { claudeQqNotificationOutboxStats, notificationTargetConfigured, notificationTargetLabel } from "./notifications";
|
||||
import { executionProviderOptions } from "./provider-runtime";
|
||||
import { executionModeOptions, executionProviderOptions } from "./provider-runtime";
|
||||
import { taskFullOutput } from "./task-output";
|
||||
import { applyOaTraceStatsToTaskJson, taskScopeId, type OaTraceStats } from "./oa-events";
|
||||
import { buildCompactTaskTranscript, buildTaskTranscript, cachedPreviewTranscript, fullTranscript, prefixPreview, safePreview, statsDaysFromUrl, taskForCompactMetaResponse, taskForMetaResponse, taskStatisticsSummary, taskTiming, timestampMs } from "./task-view";
|
||||
@@ -12,7 +12,7 @@ import type { ActiveRun, ActiveRunSlotWaiter } from "./code-agent/common";
|
||||
import type { JsonValue, QueueRecord, QueuedStatusReason, QueueTask, RuntimeConfig, TaskStatus, TranscriptLine } from "./types";
|
||||
|
||||
export interface QueueApiContext {
|
||||
config: Pick<RuntimeConfig, "codeModels" | "codexModels" | "codexSqliteLogExportBatchSize" | "codexSqliteLogExportEnabled" | "codexSqliteLogExportIntervalMs" | "codexSqliteLogMaxBytes" | "defaultModel" | "defaultReasoningEffort" | "defaultWorkdir" | "judgeMaxTokens" | "judgeRepairAttempts" | "mainProviderId" | "maxActiveQueues" | "maxInMemoryEventRecords" | "maxInMemoryOutputRecords" | "minimaxApiKey" | "minimaxModel" | "modelReasoningEfforts" | "notifyClaudeQqBaseUrl" | "notifyClaudeQqEnabled" | "notifyClaudeQqMaxResponseChars" | "notifyClaudeQqRetryIntervalMs" | "notifyClaudeQqSendAttempts" | "notifyClaudeQqTargetType" | "notifyClaudeQqTimeoutMs" | "outputArchiveDir" | "remoteDefaultWorkdir">;
|
||||
config: Pick<RuntimeConfig, "codeModels" | "codexModels" | "codexSqliteLogExportBatchSize" | "codexSqliteLogExportEnabled" | "codexSqliteLogExportIntervalMs" | "codexSqliteLogMaxBytes" | "defaultModel" | "defaultReasoningEffort" | "defaultWorkdir" | "judgeMaxTokens" | "judgeRepairAttempts" | "mainProviderId" | "maxActiveQueues" | "maxInMemoryEventRecords" | "maxInMemoryOutputRecords" | "minimaxApiKey" | "minimaxModel" | "modelReasoningEfforts" | "notifyClaudeQqBaseUrl" | "notifyClaudeQqEnabled" | "notifyClaudeQqMaxResponseChars" | "notifyClaudeQqRetryIntervalMs" | "notifyClaudeQqSendAttempts" | "notifyClaudeQqTargetType" | "notifyClaudeQqTimeoutMs" | "outputArchiveDir" | "remoteDefaultWorkdir" | "windowsNativeCodexDefaultWorkdir">;
|
||||
activeRunSlotQueueIds: () => string[];
|
||||
activeRunSlotWaiterSummaries: () => JsonValue[];
|
||||
activeRuns: Map<string, ActiveRun>;
|
||||
@@ -186,6 +186,8 @@ function taskForListResponse(task: QueueTask, lite = false, queueTasks?: QueueTa
|
||||
truncated: task.referenceInjection.truncated,
|
||||
},
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
executionModeInfo: codeExecutionModeInfo(task.executionMode),
|
||||
cwd: task.cwd,
|
||||
model: task.model,
|
||||
agentPort: codeAgentPortForModel(task.model),
|
||||
@@ -240,6 +242,8 @@ function taskForListResponse(task: QueueTask, lite = false, queueTasks?: QueueTa
|
||||
referenceTaskIds: task.referenceTaskIds,
|
||||
referenceInjection: task.referenceInjection,
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
executionModeInfo: codeExecutionModeInfo(task.executionMode),
|
||||
cwd: task.cwd,
|
||||
model: task.model,
|
||||
agentPort: codeAgentPortForModel(task.model),
|
||||
@@ -376,6 +380,8 @@ function queueSummary(includeDevReady = true, tasks: QueueTask[] = ctx().tasks()
|
||||
codexModels: ctx().config.codexModels,
|
||||
opencodeModels: opencodeModelsFor(ctx().config.codeModels),
|
||||
modelPorts: codeModelPortsFor(ctx().config.codeModels) as unknown as JsonValue,
|
||||
executionModes: executionModeOptions(),
|
||||
executionModeInfo: Object.fromEntries(codeExecutionModes.map((mode) => [mode, codeExecutionModeInfo(mode)])) as unknown as JsonValue,
|
||||
agentPorts: {
|
||||
codex: codeAgentPortInfo("codex"),
|
||||
opencode: codeAgentPortInfo("opencode"),
|
||||
@@ -386,6 +392,7 @@ function queueSummary(includeDevReady = true, tasks: QueueTask[] = ctx().tasks()
|
||||
mainProviderId: ctx().config.mainProviderId,
|
||||
defaultWorkdir: ctx().config.defaultWorkdir,
|
||||
remoteDefaultWorkdir: ctx().config.remoteDefaultWorkdir,
|
||||
windowsNativeCodexDefaultWorkdir: ctx().config.windowsNativeCodexDefaultWorkdir,
|
||||
maxActiveQueues: ctx().config.maxActiveQueues,
|
||||
executionProviders: executionProviderOptions(),
|
||||
defaultWorkdirByProvider: Object.fromEntries((executionProviderOptions() as Array<Record<string, JsonValue>>).map((provider) => [String(provider.id), provider.defaultWorkdir ?? ctx().config.defaultWorkdir])) as JsonValue,
|
||||
@@ -586,6 +593,7 @@ function taskMatchesSearch(task: QueueTask, terms: string[]): boolean {
|
||||
queued.queuedReason?.code ?? "",
|
||||
queued.queuedReason?.message ?? "",
|
||||
task.providerId,
|
||||
task.executionMode,
|
||||
task.cwd,
|
||||
task.model,
|
||||
task.reasoningEffort ?? "",
|
||||
|
||||
@@ -56,6 +56,7 @@ function referenceSummaryItem(task: QueueTask, round: number, roundIndex: number
|
||||
viaTaskId,
|
||||
status: task.status,
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
model: task.model,
|
||||
cwd: task.cwd,
|
||||
createdAt: task.createdAt,
|
||||
|
||||
@@ -56,6 +56,7 @@ function testTask(id: string, prompt: string, finalResponse: string, referenceTa
|
||||
cwd: ctx().config.defaultWorkdir,
|
||||
model: ctx().config.defaultModel,
|
||||
reasoningEffort: ctx().resolveReasoningEffort(ctx().config.defaultModel, ctx().config.defaultReasoningEffort),
|
||||
executionMode: "default",
|
||||
maxAttempts: 1,
|
||||
status: "succeeded",
|
||||
createdAt,
|
||||
@@ -344,6 +345,28 @@ function runTracePortSelfTest(): JsonValue {
|
||||
assertReferenceTest(String(edited.bodyPreview || "").includes("+const after = true;"), "opencode edit should use metadata diff for line diff display");
|
||||
assertReferenceTest(!transcript.some((line) => line.status === "opencode/step-start" || line.status === "opencode/step-finish"), "opencode step boundaries should stay out of trace");
|
||||
assertReferenceTest(!transcript.some((line) => String(line.bodyPreview || "").includes("<think>hidden reasoning</think>")), "reasoning-only opencode assistant text should not duplicate reasoning");
|
||||
|
||||
const codexTask = testTask("codex_5002_interleaved_command", "codex command prompt", "", [], "2026-05-12T00:02:00.000Z");
|
||||
codexTask.output = [
|
||||
{ seq: 10, at: "2026-05-12T00:02:00.000Z", channel: "command", method: "item/started", itemId: "call_long", text: "item/started: /bin/bash -lc \"python3 - <<'PY'\\nprint('hello')\\nPY\" status=inProgress\n" },
|
||||
{ seq: 11, at: "2026-05-12T00:02:01.000Z", channel: "command", method: "item/commandExecution/outputDelta", itemId: "call_long", text: "first line\n" },
|
||||
{ seq: 12, at: "2026-05-12T00:02:02.000Z", channel: "system", method: "startup", text: "Service restarted while task was active; task queued for retry\n" },
|
||||
{ seq: 13, at: "2026-05-12T00:02:03.000Z", channel: "command", method: "item/commandExecution/outputDelta", itemId: "call_long", text: "second line\n" },
|
||||
{ seq: 14, at: "2026-05-12T00:02:04.000Z", channel: "command", method: "item/started", itemId: "call_other", text: "item/started: /bin/bash -lc \"git status --short\" status=inProgress\n" },
|
||||
{ seq: 15, at: "2026-05-12T00:02:05.000Z", channel: "command", method: "item/completed", itemId: "call_other", text: "item/completed: /bin/bash -lc \"git status --short\" status=completed\n" },
|
||||
{ seq: 16, at: "2026-05-12T00:02:06.000Z", channel: "command", method: "item/commandExecution/outputDelta", itemId: "call_long", text: "third line\n" },
|
||||
{ seq: 17, at: "2026-05-12T00:02:07.000Z", channel: "command", method: "item/completed", itemId: "call_long", text: "item/completed: /bin/bash -lc \"python3 - <<'PY'\\nprint('hello')\\nPY\" status=completed\n" },
|
||||
];
|
||||
const codexTranscript = buildTaskTranscript(codexTask, 20, 0);
|
||||
const longCommandLines = codexTranscript.filter((line) => line.rawSeqs.includes(11) || line.rawSeqs.includes(13) || line.rawSeqs.includes(16));
|
||||
assertReferenceTest(longCommandLines.length === 1, "interleaved command output deltas should stay in one trace command line");
|
||||
const longCommand = longCommandLines[0];
|
||||
if (longCommand === undefined) throw new Error("interleaved command trace line missing");
|
||||
assertReferenceTest(longCommand.kind === "ran", "interleaved shell command should be a ran trace line");
|
||||
assertReferenceTest(String(longCommand.commandPreview || "").includes("python3 - <<"), "interleaved command trace line should retain command preview");
|
||||
assertReferenceTest(["first line", "second line", "third line"].every((part) => String(longCommand.bodyPreview || "").includes(part)), "interleaved command trace line should aggregate all output chunks");
|
||||
assertReferenceTest(transcriptLineSummaryLines(longCommand).some((line) => line.includes("$ python3 - <<")), "interleaved command summary should expose the command before expansion");
|
||||
|
||||
const remoteTask = testTask("codex_5001_remote_opencode", "remote command prompt", "", [], "2026-05-12T00:01:00.000Z");
|
||||
remoteTask.providerId = "D601";
|
||||
remoteTask.cwd = "/home/ubuntu";
|
||||
@@ -363,6 +386,8 @@ function runTracePortSelfTest(): JsonValue {
|
||||
{ name: "partial_opencode_tool_to_explored", ok: true, title: partial?.title ?? null },
|
||||
{ name: "step_boundaries_filtered", ok: true },
|
||||
{ name: "reasoning_duplicate_filtered", ok: true },
|
||||
{ name: "interleaved_command_output_single_trace_line", ok: true, rawSeqs: longCommand?.rawSeqs ?? [] },
|
||||
{ name: "interleaved_command_summary_has_command", ok: true, summaryLines: longCommand ? transcriptLineSummaryLines(longCommand) : [] },
|
||||
{ name: "duration_preserved", ok: true, durationMs: explored?.durationMs ?? null },
|
||||
{ name: "remote_opencode_exec_includes_binary", ok: true },
|
||||
{ name: "opencode_exit0_final_without_step_finish_is_terminal", ok: true },
|
||||
|
||||
@@ -16,7 +16,13 @@ export interface TaskOutputContext {
|
||||
}
|
||||
|
||||
const outputArchiveSeededTasks = new Set<string>();
|
||||
const archivedOutputCache = new Map<string, { signature: string; output: LiveOutput[] }>();
|
||||
const archivedOutputCache = new Map<string, { signature: string; output: LiveOutput[]; bytes: number }>();
|
||||
const archivedOutputCacheMaxEntries = 16;
|
||||
const archivedOutputCacheMaxBytes = 32 * 1024 * 1024;
|
||||
const archivedOutputCacheMaxSingleBytes = 8 * 1024 * 1024;
|
||||
const outputGcByteInterval = 8 * 1024 * 1024;
|
||||
let archivedOutputCacheBytes = 0;
|
||||
let outputBytesSinceGc = 0;
|
||||
let context: TaskOutputContext | null = null;
|
||||
|
||||
export function configureTaskOutput(runtimeContext: TaskOutputContext): void {
|
||||
@@ -58,14 +64,60 @@ function ensureTaskOutputArchiveSeeded(task: QueueTask): void {
|
||||
|
||||
function appendOutputArchive(task: QueueTask, output: LiveOutput, op: ArchivedLiveOutput["op"], text: string): void {
|
||||
try {
|
||||
archivedOutputCache.delete(task.id);
|
||||
deleteArchivedOutputCache(task.id);
|
||||
mkdirSync(ctx().config.outputArchiveDir, { recursive: true });
|
||||
appendFileSync(taskOutputArchivePath(task.id), serializeArchivedOutput(output, op, text), "utf8");
|
||||
maybeCollectGarbageAfterOutput(text.length);
|
||||
} catch (error) {
|
||||
ctx().logger("error", "codex_output_archive_write_failed", { taskId: task.id, error: ctx().errorToJson(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function estimateCachedOutputBytes(output: LiveOutput[]): number {
|
||||
return output.reduce((total, item) => total + String(item.text || "").length * 2 + 256, 0);
|
||||
}
|
||||
|
||||
function deleteArchivedOutputCache(taskId: string): void {
|
||||
const cached = archivedOutputCache.get(taskId);
|
||||
if (cached !== undefined) {
|
||||
archivedOutputCacheBytes = Math.max(0, archivedOutputCacheBytes - cached.bytes);
|
||||
archivedOutputCache.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneArchivedOutputCache(requiredBytes = 0): void {
|
||||
while (
|
||||
archivedOutputCache.size > archivedOutputCacheMaxEntries
|
||||
|| archivedOutputCacheBytes + requiredBytes > archivedOutputCacheMaxBytes
|
||||
) {
|
||||
const firstKey = archivedOutputCache.keys().next().value;
|
||||
if (typeof firstKey !== "string") break;
|
||||
deleteArchivedOutputCache(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
function setArchivedOutputCache(taskId: string, signature: string, output: LiveOutput[]): void {
|
||||
const bytes = estimateCachedOutputBytes(output);
|
||||
if (bytes > archivedOutputCacheMaxSingleBytes) return;
|
||||
deleteArchivedOutputCache(taskId);
|
||||
pruneArchivedOutputCache(bytes);
|
||||
if (bytes > archivedOutputCacheMaxBytes) return;
|
||||
archivedOutputCache.set(taskId, { signature, output, bytes });
|
||||
archivedOutputCacheBytes += bytes;
|
||||
pruneArchivedOutputCache();
|
||||
}
|
||||
|
||||
function maybeCollectGarbageAfterOutput(textBytes: number): void {
|
||||
outputBytesSinceGc += Math.max(0, textBytes);
|
||||
if (outputBytesSinceGc < outputGcByteInterval) return;
|
||||
outputBytesSinceGc = 0;
|
||||
try {
|
||||
(Bun as unknown as { gc?: (force?: boolean) => void }).gc?.(false);
|
||||
} catch (error) {
|
||||
ctx().logger("debug", "codex_output_gc_failed", { error: ctx().errorToJson(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function archiveRecordToOutput(value: unknown): ArchivedLiveOutput | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
@@ -114,11 +166,7 @@ function archivedTaskOutput(task: QueueTask): LiveOutput[] {
|
||||
return [];
|
||||
}
|
||||
const output = Array.from(bySeq.values()).sort((left, right) => Number(left.seq) - Number(right.seq));
|
||||
archivedOutputCache.set(task.id, { signature, output });
|
||||
while (archivedOutputCache.size > 80) {
|
||||
const firstKey = archivedOutputCache.keys().next().value;
|
||||
if (typeof firstKey === "string") archivedOutputCache.delete(firstKey);
|
||||
}
|
||||
setArchivedOutputCache(task.id, signature, output);
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
TranscriptKind,
|
||||
TranscriptLine,
|
||||
} from "./types";
|
||||
import { codeAgentPortForModel, codeAgentPortInfo, extractRecord } from "./code-agent/common";
|
||||
import { codeAgentPortForModel, codeAgentPortInfo, codeExecutionModeInfo, extractRecord } from "./code-agent/common";
|
||||
import { currentTaskPromptMarker, resolvedReferenceContextTitle, stripCodeQueueEnvironmentHint, userPromptForDisplay } from "./prompts";
|
||||
import { outputArchiveSignature, taskFullOutput } from "./task-output";
|
||||
import { retryPrompt } from "./judge";
|
||||
@@ -948,25 +948,53 @@ function buildTaskTranscript(task: QueueTask, limit = 180, rawOutputWindow = 0,
|
||||
const promptHistoryLines = promptHistoryTranscriptLines(task, fullText);
|
||||
const promptHistorySeqs = new Set(promptHistoryLines.map((line) => line.seq));
|
||||
entries.push(...promptHistoryLines);
|
||||
let activeCommand: { seq: number; at: string; command: string; status?: string; body: string; rawSeqs: number[]; itemId?: string } | null = null;
|
||||
type ActiveCommand = { seq: number; at: string; command: string; status?: string; body: string; rawSeqs: number[]; itemId?: string };
|
||||
let activeCommand: ActiveCommand | null = null;
|
||||
const activeCommandsByItemId = new Map<string, ActiveCommand>();
|
||||
|
||||
const flushCommand = (): void => {
|
||||
if (activeCommand === null) return;
|
||||
const kind = commandKind(activeCommand.command);
|
||||
const output = activeCommand.itemId === undefined ? null : commandOutputs.get(activeCommand.itemId) ?? null;
|
||||
const body = activeCommand.body.length > 0 ? activeCommand.body : formatCommandOutput(output);
|
||||
const pushRawSeq = (command: ActiveCommand, seq: number): void => {
|
||||
if (!command.rawSeqs.includes(seq)) command.rawSeqs.push(seq);
|
||||
};
|
||||
|
||||
const flushCommand = (command: ActiveCommand | null = activeCommand): void => {
|
||||
if (command === null) return;
|
||||
const kind = commandKind(command.command);
|
||||
const output = command.itemId === undefined ? null : commandOutputs.get(command.itemId) ?? null;
|
||||
const body = command.body.length > 0 ? command.body : formatCommandOutput(output);
|
||||
const title = command.command.trim().length > 0 ? shortCommandTitle(command.command) : "Command output";
|
||||
entries.push(addCommandOutputStreams(transcriptLine(
|
||||
kind,
|
||||
activeCommand.at,
|
||||
activeCommand.seq,
|
||||
shortCommandTitle(activeCommand.command),
|
||||
activeCommand.rawSeqs,
|
||||
command.at,
|
||||
command.seq,
|
||||
title,
|
||||
command.rawSeqs,
|
||||
body,
|
||||
activeCommand.command,
|
||||
activeCommand.status,
|
||||
command.command,
|
||||
command.status,
|
||||
fullText,
|
||||
), output, fullText));
|
||||
activeCommand = null;
|
||||
if (command === activeCommand) activeCommand = null;
|
||||
if (command.itemId !== undefined && activeCommandsByItemId.get(command.itemId) === command) {
|
||||
activeCommandsByItemId.delete(command.itemId);
|
||||
}
|
||||
};
|
||||
|
||||
const itemIdCommand = (item: LiveOutput, parsed: { command: string; status?: string } | null = null): ActiveCommand | null => {
|
||||
if (typeof item.itemId !== "string" || item.itemId.length === 0) return null;
|
||||
let command = activeCommandsByItemId.get(item.itemId);
|
||||
if (command === undefined) {
|
||||
command = {
|
||||
seq: item.seq,
|
||||
at: item.at,
|
||||
command: parsed?.command ?? "",
|
||||
status: parsed?.status,
|
||||
body: "",
|
||||
rawSeqs: [],
|
||||
itemId: item.itemId,
|
||||
};
|
||||
activeCommandsByItemId.set(item.itemId, command);
|
||||
}
|
||||
return command;
|
||||
};
|
||||
|
||||
const outputSource = rawOutputWindow > 0 ? task.output : taskFullOutput(task);
|
||||
@@ -984,8 +1012,18 @@ function buildTaskTranscript(task: QueueTask, limit = 180, rawOutputWindow = 0,
|
||||
if (item.channel === "user" && item.method === "turn/steer" && promptHistorySeqs.has(item.seq)) continue;
|
||||
if (isOpenCodeStepBoundaryMethod(item.method)) continue;
|
||||
if (item.channel === "command" && item.method === "item/started") {
|
||||
flushCommand();
|
||||
const parsed = parseCommandLine(item.text);
|
||||
const groupedCommand = itemIdCommand(item, parsed);
|
||||
if (groupedCommand !== null) {
|
||||
flushCommand();
|
||||
groupedCommand.seq = item.seq;
|
||||
groupedCommand.at = item.at;
|
||||
groupedCommand.command = parsed?.command || item.text;
|
||||
groupedCommand.status = parsed?.status;
|
||||
pushRawSeq(groupedCommand, item.seq);
|
||||
continue;
|
||||
}
|
||||
flushCommand();
|
||||
activeCommand = {
|
||||
seq: item.seq,
|
||||
at: item.at,
|
||||
@@ -998,9 +1036,13 @@ function buildTaskTranscript(task: QueueTask, limit = 180, rawOutputWindow = 0,
|
||||
continue;
|
||||
}
|
||||
if (item.channel === "command" && item.method === "item/commandExecution/outputDelta") {
|
||||
if (activeCommand !== null) {
|
||||
const groupedCommand = itemIdCommand(item);
|
||||
if (groupedCommand !== null) {
|
||||
groupedCommand.body += item.text;
|
||||
pushRawSeq(groupedCommand, item.seq);
|
||||
} else if (activeCommand !== null) {
|
||||
activeCommand.body += item.text;
|
||||
activeCommand.rawSeqs.push(item.seq);
|
||||
pushRawSeq(activeCommand, item.seq);
|
||||
} else {
|
||||
const output = typeof item.itemId === "string" ? commandOutputs.get(item.itemId) ?? null : null;
|
||||
entries.push(addCommandOutputStreams(transcriptLine("ran", item.at, item.seq, "Command output", [item.seq], item.text || formatCommandOutput(output), "", undefined, fullText), output, fullText));
|
||||
@@ -1009,9 +1051,15 @@ function buildTaskTranscript(task: QueueTask, limit = 180, rawOutputWindow = 0,
|
||||
}
|
||||
if (item.channel === "command" && item.method === "item/completed") {
|
||||
const parsed = parseCommandLine(item.text);
|
||||
if (activeCommand !== null) {
|
||||
const groupedCommand = itemIdCommand(item, parsed);
|
||||
if (groupedCommand !== null) {
|
||||
if (parsed?.command) groupedCommand.command = parsed.command;
|
||||
groupedCommand.status = parsed?.status ?? groupedCommand.status;
|
||||
pushRawSeq(groupedCommand, item.seq);
|
||||
flushCommand(groupedCommand);
|
||||
} else if (activeCommand !== null) {
|
||||
activeCommand.status = parsed?.status ?? activeCommand.status;
|
||||
activeCommand.rawSeqs.push(item.seq);
|
||||
pushRawSeq(activeCommand, item.seq);
|
||||
flushCommand();
|
||||
} else {
|
||||
const command = parsed?.command || item.text;
|
||||
@@ -1049,73 +1097,14 @@ function buildTaskTranscript(task: QueueTask, limit = 180, rawOutputWindow = 0,
|
||||
}
|
||||
}
|
||||
flushCommand();
|
||||
for (const command of Array.from(activeCommandsByItemId.values()).sort((left, right) => left.seq - right.seq)) {
|
||||
flushCommand(command);
|
||||
}
|
||||
return boundedTranscript(entries, limit);
|
||||
}
|
||||
|
||||
function compactTaskTranscriptLine(item: LiveOutput, title: string, kind: TranscriptKind): TranscriptLine {
|
||||
return {
|
||||
seq: item.seq,
|
||||
at: item.at,
|
||||
kind,
|
||||
title,
|
||||
status: item.method,
|
||||
bodyPreview: prefixPreview(item.text.replace(/\u001b\[[0-9;]*m/gu, ""), 900),
|
||||
rawSeqs: [item.seq],
|
||||
};
|
||||
}
|
||||
|
||||
function buildCompactTaskTranscript(task: QueueTask, limit = 12, rawOutputWindow = 24): TranscriptLine[] {
|
||||
const entries: TranscriptLine[] = [];
|
||||
for (const item of task.promptHistory.slice(-2)) {
|
||||
entries.push({
|
||||
seq: item.seq,
|
||||
at: item.at,
|
||||
kind: "message",
|
||||
title: "Steer prompt",
|
||||
status: item.method,
|
||||
bodyPreview: prefixPreview(item.text, 900),
|
||||
rawSeqs: [item.seq],
|
||||
});
|
||||
}
|
||||
const outputItems = task.output.slice(-rawOutputWindow);
|
||||
const fileChangeInputs = outputItems.some((item) => item.channel === "diff" && item.method === "item/fileChange/outputDelta" && typeof item.itemId === "string")
|
||||
? codexSessionFileChangesByCallId(task)
|
||||
: new Map<string, SessionFileChange>();
|
||||
for (const item of outputItems) {
|
||||
if (item.channel === "user" && item.method === "enqueue") continue;
|
||||
if (isOpenCodeStepBoundaryMethod(item.method)) continue;
|
||||
if (item.channel === "command") {
|
||||
const isOutput = item.method === "item/commandExecution/outputDelta";
|
||||
entries.push({
|
||||
seq: item.seq,
|
||||
at: item.at,
|
||||
kind: isOutput ? "ran" : item.method === "item/started" ? "ran" : "system",
|
||||
title: isOutput ? "Command output" : item.method === "item/started" ? "Command started" : "Command completed",
|
||||
status: item.method,
|
||||
commandPreview: isOutput ? undefined : prefixPreview(item.text, 900),
|
||||
bodyPreview: isOutput ? prefixPreview(item.text, 900) : undefined,
|
||||
rawSeqs: [item.seq],
|
||||
});
|
||||
} else if (item.channel === "diff") {
|
||||
entries.push(compactTaskTranscriptLine({ ...item, text: fileChangeTextWithInlinePatch(item, fileChangeInputs) }, "Edited files", "edited"));
|
||||
} else if (item.channel === "error") {
|
||||
entries.push(compactTaskTranscriptLine(item, "Error", "error"));
|
||||
} else if (item.channel === "assistant" || item.channel === "reasoning" || item.channel === "user") {
|
||||
const body = item.channel === "assistant" && String(item.method || "").startsWith("opencode/") ? openCodeVisibleAssistantText(item.text) : item.text;
|
||||
if (body.length > 0) entries.push(compactTaskTranscriptLine({ ...item, text: body }, item.channel === "assistant" ? "Assistant message" : item.channel === "reasoning" ? "Reasoning" : "User prompt", "message"));
|
||||
} else if (item.channel === "tool" && String(item.method || "").startsWith("opencode/")) {
|
||||
const line = openCodeToolTranscriptLine(item, false);
|
||||
entries.push(line ?? compactTaskTranscriptLine(item, "OpenCode tool", "system"));
|
||||
} else {
|
||||
const title = item.method === "judge"
|
||||
? "Judge result"
|
||||
: item.method === "startup" || item.method === "shutdown"
|
||||
? "Recovered thread execution"
|
||||
: "System";
|
||||
entries.push(compactTaskTranscriptLine(item, title, "system"));
|
||||
}
|
||||
}
|
||||
return boundedTranscript(entries, limit);
|
||||
return buildTaskTranscript(task, limit, rawOutputWindow, false);
|
||||
}
|
||||
|
||||
function transcriptSignature(task: QueueTask): string {
|
||||
@@ -1269,6 +1258,8 @@ function taskForMetaResponse(task: QueueTask): JsonValue {
|
||||
referenceTaskIds: task.referenceTaskIds,
|
||||
referenceInjection: task.referenceInjection,
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
executionModeInfo: codeExecutionModeInfo(task.executionMode),
|
||||
cwd: task.cwd,
|
||||
model: task.model,
|
||||
agentPort: codeAgentPortForModel(task.model),
|
||||
@@ -1337,6 +1328,8 @@ function taskForCompactMetaResponse(task: QueueTask): JsonValue {
|
||||
truncated: task.referenceInjection.truncated,
|
||||
},
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
executionModeInfo: codeExecutionModeInfo(task.executionMode),
|
||||
cwd: task.cwd,
|
||||
model: task.model,
|
||||
agentPort: codeAgentPortForModel(task.model),
|
||||
@@ -1578,11 +1571,37 @@ function parseJudgeLine(text: string): JudgeResult | null {
|
||||
if (match === null) return null;
|
||||
const confidence = Number(match[2]);
|
||||
const source = match[3] === "minimax" ? "minimax" : "fallback";
|
||||
const rawReason = (match[4] ?? "").trim();
|
||||
const detailsMatch = /\bMiniMax failure details:\s+([\s\S]*)$/u.exec(rawReason);
|
||||
const detailsText = detailsMatch?.[1] ?? "";
|
||||
const detailValue = (key: string): string | null => new RegExp(`\\b${key}=([^\\s]+)`, "u").exec(detailsText)?.[1] ?? null;
|
||||
const detailNumber = (key: string): number | undefined => {
|
||||
const value = Number(detailValue(key));
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const errorMessage = /\berror=([\s\S]*)$/u.exec(detailsText)?.[1]?.trim() ?? "";
|
||||
const failureDetails = detailsText.length === 0 ? null : {
|
||||
provider: "minimax" as const,
|
||||
stage: (detailValue("stage") || "unknown") as JudgeResult["failureDetails"] extends infer T ? T extends { stage: infer S } ? S : never : never,
|
||||
model: "",
|
||||
apiBase: "",
|
||||
occurredAt: "",
|
||||
durationMs: detailNumber("durationMs") ?? 0,
|
||||
timeoutMs: detailNumber("timeoutMs") ?? 0,
|
||||
timedOut: detailValue("timedOut") === "true",
|
||||
errorName: detailValue("errorName") || "",
|
||||
errorMessage,
|
||||
promptChars: detailNumber("promptChars"),
|
||||
promptLines: detailNumber("promptLines"),
|
||||
payloadBytes: detailNumber("payloadBytes"),
|
||||
responseStatus: detailNumber("responseStatus") ?? null,
|
||||
};
|
||||
return {
|
||||
decision: match[1] as JudgeDecision,
|
||||
confidence: Number.isFinite(confidence) ? confidence : 0,
|
||||
source,
|
||||
reason: (match[4] ?? "").trim(),
|
||||
reason: detailsMatch === null ? rawReason : rawReason.slice(0, detailsMatch.index).trim(),
|
||||
failureDetails,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1680,6 +1699,23 @@ function isRecoveredThreadLine(line: TranscriptLine): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function traceSystemLineIsError(line: TranscriptLine): boolean {
|
||||
const text = [
|
||||
line.title,
|
||||
line.status,
|
||||
line.bodyPreview,
|
||||
line.commandPreview,
|
||||
line.stderrPreview,
|
||||
line.stdoutPreview,
|
||||
].map((value) => String(value || "")).join("\n");
|
||||
return /\b(error|failed|failure|interrupt|interrupted|cancell?ed|watchdog|timeout|closed|refused|aborted|exception)\b/iu.test(text);
|
||||
}
|
||||
|
||||
function traceLineVisibleInTraceView(line: TranscriptLine): boolean {
|
||||
if (line.title === "Submitted prompt") return false;
|
||||
return line.kind !== "system" || traceSystemLineIsError(line);
|
||||
}
|
||||
|
||||
function mergeTraceWindowLines(left: TranscriptLine[], right: TranscriptLine[]): TranscriptLine[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: TranscriptLine[] = [];
|
||||
@@ -1990,6 +2026,8 @@ function taskTraceSummaryResponse(task: QueueTask, oaTraceStats: JsonValue | nul
|
||||
queueId: ctx().queueIdOf(task),
|
||||
status: task.status,
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
executionModeInfo: codeExecutionModeInfo(task.executionMode),
|
||||
model: task.model,
|
||||
agentPort: codeAgentPortForModel(task.model),
|
||||
agentPortInfo: codeAgentPortInfo(codeAgentPortForModel(task.model)),
|
||||
@@ -2083,7 +2121,7 @@ function taskTraceStepsResponse(task: QueueTask, url: URL): Response {
|
||||
const previewTranscript = cachedPreviewTranscript(task);
|
||||
const attemptWindow = attemptIndex === null ? null : traceAttemptWindows(task, previewTranscript).find((window) => window.index === attemptIndex) ?? null;
|
||||
const sourceTranscript = attemptWindow === null ? previewTranscript : executionLinesForAttempt(attemptWindow.lines);
|
||||
const transcript = sourceTranscript.filter((line) => line.title !== "Submitted prompt");
|
||||
const transcript = sourceTranscript.filter(traceLineVisibleInTraceView);
|
||||
const page = ctx().pageBySeq(transcript, url, limit);
|
||||
return ctx().jsonResponse({
|
||||
ok: true,
|
||||
@@ -2121,7 +2159,7 @@ function taskTraceStepDetailResponse(task: QueueTask, url: URL): Response {
|
||||
const seq = Number(url.searchParams.get("seq"));
|
||||
if (!Number.isFinite(seq)) return ctx().jsonResponse({ ok: false, error: "seq is required" }, 400);
|
||||
const agentPort = codeAgentPortForModel(task.model);
|
||||
const transcript = fullTranscript(task).filter((line) => line.title !== "Submitted prompt");
|
||||
const transcript = fullTranscript(task).filter(traceLineVisibleInTraceView);
|
||||
const line = transcript.find((item) => Number(item.seq) === seq || item.rawSeqs.includes(seq));
|
||||
if (line === undefined) return ctx().jsonResponse({ ok: false, error: "trace step not found", seq }, 404);
|
||||
return ctx().jsonResponse({
|
||||
@@ -2145,6 +2183,8 @@ function taskSummaryResponse(task: QueueTask, url: URL): JsonValue {
|
||||
queueId: ctx().queueIdOf(task),
|
||||
status: task.status,
|
||||
providerId: task.providerId,
|
||||
executionMode: task.executionMode,
|
||||
executionModeInfo: codeExecutionModeInfo(task.executionMode),
|
||||
model: task.model,
|
||||
agentPort: codeAgentPortForModel(task.model),
|
||||
agentPortInfo: codeAgentPortInfo(codeAgentPortForModel(task.model)),
|
||||
|
||||
@@ -20,6 +20,8 @@ export type RunMode = "initial" | "retry";
|
||||
|
||||
export type JudgeDecision = "complete" | "retry" | "fail";
|
||||
|
||||
export type CodeExecutionMode = "default" | "windows-native";
|
||||
|
||||
export type OutputChannel = "system" | "user" | "assistant" | "reasoning" | "command" | "diff" | "tool" | "error";
|
||||
|
||||
export type TerminalStatus = "completed" | "interrupted" | "failed" | null;
|
||||
@@ -81,6 +83,11 @@ export interface RuntimeConfig {
|
||||
devContainerDefaultProviderId: string;
|
||||
devContainerImage: string;
|
||||
devContainerWorkdir: string;
|
||||
windowsNativeCodexBridgeDir: string;
|
||||
windowsNativeCodexCommand: string;
|
||||
windowsNativeCodexConnectHost: string;
|
||||
windowsNativeCodexDefaultWorkdir: string;
|
||||
windowsNativeCodexIdleTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface QueueTaskRequest {
|
||||
@@ -90,6 +97,7 @@ export interface QueueTaskRequest {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
reasoningEffort?: string;
|
||||
executionMode?: CodeExecutionMode;
|
||||
maxAttempts?: number;
|
||||
referenceTaskIds?: string[];
|
||||
basePrompt?: string;
|
||||
@@ -145,11 +153,14 @@ export interface AttemptSummary {
|
||||
mode: RunMode;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
providerId?: string;
|
||||
executionMode?: CodeExecutionMode;
|
||||
terminalStatus: TerminalStatus;
|
||||
transportClosedBeforeTerminal: boolean;
|
||||
appServerExitCode: number | null;
|
||||
appServerSignal: string | null;
|
||||
error: string | null;
|
||||
events?: CodexEventSummary[];
|
||||
inputPrompt?: string;
|
||||
inputPromptPreview?: string;
|
||||
inputPromptChars?: number;
|
||||
@@ -178,9 +189,33 @@ export interface JudgeResult {
|
||||
reason: string;
|
||||
continuePrompt?: string;
|
||||
source: "minimax" | "fallback";
|
||||
failureDetails?: JudgeFailureDetails | null;
|
||||
raw?: JsonValue;
|
||||
}
|
||||
|
||||
export interface JudgeFailureDetails {
|
||||
provider: "minimax";
|
||||
stage: "request" | "http" | "parse" | "validation" | "unknown";
|
||||
model: string;
|
||||
apiBase: string;
|
||||
occurredAt: string;
|
||||
durationMs: number;
|
||||
timeoutMs: number;
|
||||
timedOut: boolean;
|
||||
errorName: string;
|
||||
errorMessage: string;
|
||||
repairAttempt?: number;
|
||||
maxRepairAttempts?: number;
|
||||
promptChars?: number;
|
||||
promptLines?: number;
|
||||
payloadBytes?: number;
|
||||
responseStatus?: number | null;
|
||||
responseTextPreview?: string;
|
||||
responseTextChars?: number;
|
||||
responseContentPreview?: string;
|
||||
responseContentChars?: number;
|
||||
}
|
||||
|
||||
export interface FeedbackPromptRecord {
|
||||
text: string;
|
||||
preview: string;
|
||||
@@ -199,6 +234,19 @@ export interface ParsedJudgeJson {
|
||||
export interface MiniMaxJudgeResponse {
|
||||
rawText: string;
|
||||
content: string;
|
||||
diagnostics: {
|
||||
provider: "minimax";
|
||||
model: string;
|
||||
apiBase: string;
|
||||
durationMs: number;
|
||||
timeoutMs: number;
|
||||
promptChars: number;
|
||||
promptLines: number;
|
||||
payloadBytes: number;
|
||||
responseStatus: number;
|
||||
responseTextChars: number;
|
||||
responseContentChars: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReferenceInjectionSummaryItem {
|
||||
@@ -208,6 +256,7 @@ export interface ReferenceInjectionSummaryItem {
|
||||
viaTaskId: string | null;
|
||||
status: TaskStatus;
|
||||
providerId: string;
|
||||
executionMode?: CodeExecutionMode;
|
||||
model: string;
|
||||
cwd: string;
|
||||
createdAt: string;
|
||||
@@ -250,6 +299,7 @@ export interface QueueTask {
|
||||
cwd: string;
|
||||
model: string;
|
||||
reasoningEffort: string | null;
|
||||
executionMode: CodeExecutionMode;
|
||||
maxAttempts: number;
|
||||
status: TaskStatus;
|
||||
createdAt: string;
|
||||
|
||||
@@ -643,13 +643,13 @@ async function applyTraceSnapshot(executor: SqlExecutor, event: OaEventRecord, s
|
||||
subject_kind = EXCLUDED.subject_kind,
|
||||
subject_id = EXCLUDED.subject_id,
|
||||
stats_revision = oa_trace_stats.stats_revision + 1,
|
||||
step_count = GREATEST(oa_trace_stats.step_count, EXCLUDED.step_count),
|
||||
llm_step_count = GREATEST(oa_trace_stats.llm_step_count, EXCLUDED.llm_step_count),
|
||||
read_count = GREATEST(oa_trace_stats.read_count, EXCLUDED.read_count),
|
||||
edit_count = GREATEST(oa_trace_stats.edit_count, EXCLUDED.edit_count),
|
||||
run_count = GREATEST(oa_trace_stats.run_count, EXCLUDED.run_count),
|
||||
error_count = GREATEST(oa_trace_stats.error_count, EXCLUDED.error_count),
|
||||
trace_line_count = GREATEST(oa_trace_stats.trace_line_count, EXCLUDED.trace_line_count),
|
||||
step_count = EXCLUDED.step_count,
|
||||
llm_step_count = EXCLUDED.llm_step_count,
|
||||
read_count = EXCLUDED.read_count,
|
||||
edit_count = EXCLUDED.edit_count,
|
||||
run_count = EXCLUDED.run_count,
|
||||
error_count = EXCLUDED.error_count,
|
||||
trace_line_count = EXCLUDED.trace_line_count,
|
||||
output_max_seq = GREATEST(oa_trace_stats.output_max_seq, EXCLUDED.output_max_seq),
|
||||
attempt_stats_json = EXCLUDED.attempt_stats_json,
|
||||
last_event_sequence = GREATEST(COALESCE(oa_trace_stats.last_event_sequence, 0), EXCLUDED.last_event_sequence),
|
||||
@@ -688,7 +688,7 @@ async function applyTraceStep(executor: SqlExecutor, event: OaEventRecord, scope
|
||||
error_count: string | number;
|
||||
}[]>`
|
||||
SELECT
|
||||
COUNT(*) AS step_count,
|
||||
COUNT(*) FILTER (WHERE kind <> 'system') AS step_count,
|
||||
COUNT(*) FILTER (WHERE kind = 'read') AS read_count,
|
||||
COUNT(*) FILTER (WHERE kind = 'edit') AS edit_count,
|
||||
COUNT(*) FILTER (WHERE kind = 'run') AS run_count,
|
||||
@@ -718,13 +718,13 @@ async function applyTraceStep(executor: SqlExecutor, event: OaEventRecord, scope
|
||||
subject_kind = EXCLUDED.subject_kind,
|
||||
subject_id = EXCLUDED.subject_id,
|
||||
stats_revision = oa_trace_stats.stats_revision + 1,
|
||||
step_count = GREATEST(oa_trace_stats.step_count, EXCLUDED.step_count),
|
||||
llm_step_count = GREATEST(oa_trace_stats.llm_step_count, EXCLUDED.llm_step_count),
|
||||
read_count = GREATEST(oa_trace_stats.read_count, EXCLUDED.read_count),
|
||||
edit_count = GREATEST(oa_trace_stats.edit_count, EXCLUDED.edit_count),
|
||||
run_count = GREATEST(oa_trace_stats.run_count, EXCLUDED.run_count),
|
||||
error_count = GREATEST(oa_trace_stats.error_count, EXCLUDED.error_count),
|
||||
trace_line_count = GREATEST(oa_trace_stats.trace_line_count, EXCLUDED.trace_line_count),
|
||||
step_count = EXCLUDED.step_count,
|
||||
llm_step_count = EXCLUDED.llm_step_count,
|
||||
read_count = EXCLUDED.read_count,
|
||||
edit_count = EXCLUDED.edit_count,
|
||||
run_count = EXCLUDED.run_count,
|
||||
error_count = EXCLUDED.error_count,
|
||||
trace_line_count = EXCLUDED.trace_line_count,
|
||||
output_max_seq = GREATEST(oa_trace_stats.output_max_seq, EXCLUDED.output_max_seq),
|
||||
attempt_stats_json = oa_trace_stats.attempt_stats_json,
|
||||
last_event_sequence = GREATEST(COALESCE(oa_trace_stats.last_event_sequence, 0), EXCLUDED.last_event_sequence),
|
||||
|
||||
Reference in New Issue
Block a user