initial real project snapshot
This commit is contained in:
commit
b49b9cf1e7
18 files changed
+53739
No files matched your search
+10
@@ -0,0 +1,10 @@
|
|||||||
|
bin/
|
||||||
|
work/
|
||||||
|
host-state.json
|
||||||
|
host-state.json.lock
|
||||||
|
host-state-real.json
|
||||||
|
host-state-real.json.lock
|
||||||
|
host-run.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.tmp.*
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Codex app-server(无 GUI 版,照搬开源核心)
|
||||||
|
|
||||||
|
目标:不做桌面 UI、不重写 agent 循环,把 Codex 开源能给到的全部
|
||||||
|
原样接过来,做成一个可以被任何客户端(pi agent、你自己的 App、
|
||||||
|
脚本)调用的 app-server。
|
||||||
|
|
||||||
|
## 一句话架构
|
||||||
|
|
||||||
|
```
|
||||||
|
你的客户端 / pi agent
|
||||||
|
│ stdio JSON-RPC(或 unix / ws,见 start.sh)
|
||||||
|
▼
|
||||||
|
client.py ──► codex app-server(官方二进制,codex-core 原样)
|
||||||
|
│ thread/start · turn/start · 事件流 · 审批请求
|
||||||
|
▼
|
||||||
|
agent 循环 + 沙箱 + 工具 + MCP + skills + 线程持久化
|
||||||
|
```
|
||||||
|
|
||||||
|
本仓库自己写的只有宿主层:`client.py`(协议客户端)、
|
||||||
|
`run_parallel.py`(多 thread 编排)、`start.sh`、`config.example.toml`。
|
||||||
|
agent 循环、沙箱、工具执行、上下文压缩、线程存储,全部是官方
|
||||||
|
codex 二进制里的,不是我复刻的简化版。
|
||||||
|
|
||||||
|
实测版本:本机 `codex-cli 0.164.0-alpha.9`,协议定义就是从它
|
||||||
|
导出的,见 `protocol/`。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `start.sh`:启动官方 app-server,支持 stdio / unix / ws 三种传输。
|
||||||
|
- `client.py`:零依赖 Python 客户端,演示完整链路。
|
||||||
|
- `run_parallel.py`:宿主层多 agent 编排(见下文“子代理”)。
|
||||||
|
- `config.example.toml`:沙箱、审批、MCP、features 的配置示例。
|
||||||
|
- `protocol/`:`codex app-server generate-json-schema` 原样导出的
|
||||||
|
协议 schema(v1 + v2),客户端的方法名和字段以它为准。
|
||||||
|
- `examples/subagent-prompt.txt`:让主 agent 派生子代理的 prompt 模板。
|
||||||
|
- `examples/jobs.json`:并行审查的 job 示例。
|
||||||
|
- `work/job-a`、`work/job-b`:并行示例用的两个只读样例目录。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 先确认登录(这一步不在开源里)
|
||||||
|
|
||||||
|
在你自己的机器上先跑一次 `codex login`(ChatGPT 登录),
|
||||||
|
或配好带 API key 的 model provider。否则会出现下面实测里
|
||||||
|
的状况:thread 能建、turn 能发,但模型调用返回 401,
|
||||||
|
turn 以 failed 结束——这是登录问题,不是 app-server 问题。
|
||||||
|
|
||||||
|
### 2. 跑一回合
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/workspace/codex-app-server
|
||||||
|
python3 client.py --prompt "修掉 README 里的错别字" \
|
||||||
|
--cwd /path/to/your/repo \
|
||||||
|
--sandbox workspace-write --approval-policy on-request
|
||||||
|
```
|
||||||
|
|
||||||
|
只看协议、不跑模型:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 client.py --list-threads
|
||||||
|
python3 client.py --thread-id <上次的 thread id> --prompt "继续"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 换传输方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start.sh --listen unix:///tmp/codex.sock
|
||||||
|
./start.sh --listen ws://127.0.0.1:8080
|
||||||
|
# 再用你自己的客户端连这个 socket / ws,而不是 stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 配置
|
||||||
|
|
||||||
|
把 `config.example.toml` 复制到 `~/.codex/config.toml` 再改,
|
||||||
|
不要把登录信息、API key、你自己的 relay 地址写进本仓库。
|
||||||
|
沙箱三档:`read-only` / `workspace-write` / `danger-full-access`;
|
||||||
|
审批三档:`untrusted` / `on-request` / `never`。
|
||||||
|
|
||||||
|
## 开源照搬清单(全部来自官方 app-server)
|
||||||
|
|
||||||
|
| 能力 | 来源 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| Agent 循环 | codex-core | 推理 → 工具 → 结果 → 再推理,直到 turn 完成 |
|
||||||
|
| Thread 生命周期 | app-server | thread/start、resume、fork、archive、delete、list,事件历史持久化 |
|
||||||
|
| Turn 与流式事件 | app-server | turn/start、turn/steer、turn/interrupt、turn/completed,agent 文本/工具事件流式回推 |
|
||||||
|
| 沙箱 | codex-core | macOS Seatbelt、Linux Landlock/bubblewrap;本机 VM 的系统 codex 缺 host 和 bwrap,工具会 fail closed,已用 `bin/` 配对三件套修复,见 TEST-REPORT.md“工具执行修复” |
|
||||||
|
| 审批 | app-server | 命令、改文件、权限、MCP 询问等请求回推给客户端裁决;也可配 `approvalsReviewer=auto_review` 让子代理先审 |
|
||||||
|
| 工具 | codex-core | shell、文件读写、搜索、改代码(apply_patch)、图片等 |
|
||||||
|
| MCP / skills / 插件 | app-server | thread 内按需加载,`features list` 可看本版开关 |
|
||||||
|
| AGENTS.md | codex-core | 全局、仓库根、子目录三层合并,thread 启动时自动读 |
|
||||||
|
| 上下文压缩 | codex-core | 长会话自动 compact,不用客户端自己截断 |
|
||||||
|
| exec-server 拆分 | codex-core | 推理(app-server)与执行(exec-server)可分开部署,见 `codex exec-server --help` |
|
||||||
|
| Goal / 多 agent 开关 | features | 本版 `multi_agent` 已是 stable,`codex features list` 可验证 |
|
||||||
|
|
||||||
|
## 子代理:两层,不要混
|
||||||
|
|
||||||
|
### 内置子代理(开源已给)
|
||||||
|
|
||||||
|
主 agent 在 prompt 明确要求时派生 worker / explorer 等子代理,
|
||||||
|
各自独立上下文、并行跑、主 agent 汇总。模板见
|
||||||
|
`examples/subagent-prompt.txt`,要点是:分工写清楚、要求分开
|
||||||
|
列结果、最后合并去重、只列有证据的项。
|
||||||
|
|
||||||
|
限制(重要):子代理继承父 thread 的沙箱和审批设置,
|
||||||
|
不是独立权限边界。“只读评审”是指令约束,不是技术隔离。
|
||||||
|
真要隔离,改代码的 job 必须走下面一层。
|
||||||
|
|
||||||
|
### 宿主编排(开源没给,自己写,已给示例)
|
||||||
|
|
||||||
|
`run_parallel.py` 就是这一层:宿主并行开多个 thread,
|
||||||
|
每个 thread 一个独立工作目录(生产上用 git worktree),
|
||||||
|
最后宿主收集结果再汇总。桌面版“多线程指挥台”的感觉
|
||||||
|
主要就是这一层,不是 app-server 本身。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 run_parallel.py --jobs examples/jobs.json
|
||||||
|
```
|
||||||
|
|
||||||
|
示例默认是只读审查(read-only + never),要并行改代码时:
|
||||||
|
每个 job 换成各自的 worktree、改成 workspace-write,
|
||||||
|
事后逐个 review diff 再合并,冲突仍需人工处理——
|
||||||
|
core 里没有自动解决冲突的调度器。
|
||||||
|
|
||||||
|
## 宿主编排(host.py,无 GUI,对齐 ChatGPT / Codex 桌面版宿主)
|
||||||
|
|
||||||
|
ChatGPT / Codex 桌面版作为宿主,本质不是再写一个 agent,而是
|
||||||
|
管好这几件:线程、并行、隔离、委派、长任务、评审、自动化、
|
||||||
|
边界。`host.py` 就是这一层,一个 app-server 进程托管多个 thread,
|
||||||
|
后台读线程按 threadId 分发 turn/completed,所以多 thread 的
|
||||||
|
turn 可以真正并发(不是轮流排队)。
|
||||||
|
|
||||||
|
| ChatGPT / 桌面版宿主能力 | host.py | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 多 thread 并行 | `host.py parallel --jobs …`,一个 app-server 进程内多 thread 并发 | 已实测 |
|
||||||
|
| 一任务一 git worktree 隔离 | `dispatch` 自动 `git worktree add -b host/<task>`,非 git 仓库退化为独立目录拷贝 | 已实测 |
|
||||||
|
| diff 评审 | `host.py review <task>`:status + diff + untracked | 已实测 |
|
||||||
|
| 评审后合并 / 丢弃 | `merge`:先在 worktree 提交,再 `--no-ff` 合并进主仓,冲突自动 abort;`discard`:删 worktree 和分支 | 已实测(host-b 已合并进主仓) |
|
||||||
|
| 线程续跑 / fork / 归档 | `run`(resume + 新回合)、`fork`、`archive`、`list` | 已实测 |
|
||||||
|
| 宿主级子代理委派 | `delegate`:子 thread(默认 read-only)跑完,结果可 `--report-to-parent` 汇总回主 thread | 已实测 |
|
||||||
|
| 模型内子代理 | prompt 明确要求派生,见 `examples/subagent-prompt.txt`;与宿主委派是两层,别混 | 已实测(模板 + 协议) |
|
||||||
|
| 长任务 / 目标循环 | `goal`:同一 thread 多回合,直到 `--verify` 验收命令通过或到 `--max-rounds` | 已实测(1 轮通过) |
|
||||||
|
| 自动化 / 评审队列 | `automations --add` + `run-due`:到期的任务自动派发,结果进 host-state.json 评审队列 | 已实测 |
|
||||||
|
| 沙箱边界 | read-only thread 的写文件请求被拦下,文件确实没建出来 | 已实测 |
|
||||||
|
| 审批控制 | untrusted + deny 时 agent 明确收到审批拒绝,没拿到命令输出 | 已实测 |
|
||||||
|
| 模型内子代理 | 3 子代理并行检查并汇总,视角是真的不同,不是重复一句话 | 已实测 |
|
||||||
|
| 中断 / 续跑控制 | `turn/interrupt` 后状态为 interrupted;`compact` 需先 resume(host.py 的 compact 命令已包好这一步) | 已实测 |
|
||||||
|
| 合并冲突处理 | 先合的进主仓,后合同一行的冲突 abort,主仓干净,冲突 worktree 留着人工解 | 已实测 |
|
||||||
|
| 自动化幂等 | 未到期的自动化 run-due 时不重复派发 | 已实测 |
|
||||||
|
| 宿主提供 MCP 工具 | 隔离 CODEX_HOME 注册 stdio MCP,agent 能发现并调用;注意 MCP 审批是 elicitation 协议,不是通用 decision 字段,host.py 已分流处理 | 已实测(修过一轮才通) |
|
||||||
|
| 模型切换 | 同一 thread 内换模型续跑,turn 的 model 字段随之改变 | 已实测 |
|
||||||
|
| 运行中改向 | `turn/steer`(expectedTurnId 校验)能把进行中的长任务改成短任务 | 已实测 |
|
||||||
|
| 3 并行 | 3 个 worktree 任务同时跑,全部完成且互不污染 | 已实测 |
|
||||||
|
| 未追踪文件评审 | review 时 diff 为空也要看 untracked 栏 | 已实测 |
|
||||||
|
| 失败不崩宿主 | 不存在的模型导致 task failed 并带上游 404 错误,host 不崩 | 已实测 |
|
||||||
|
| 生产级项目闭环 | 真实仓:并行功能开发 + 测试套件验收 + 评审修复闭环 + 合并链,主仓从 2 个测试涨到 17 个全部通过 | 已实测 |
|
||||||
|
| 目标循环反馈 | 验收失败时把命令和失败输出喂给下一轮,否则 agent 会原地不动 | 已实测(修过一轮才通) |
|
||||||
|
| 中断恢复 | dispatch/delegate/goal/run-due 先落盘再跑 turn,中断后可用 run 续跑 | 已实测(委派超时后修) |
|
||||||
|
| fork 隔离 | fork 建新 worktree + 拷工作区文件(跳过 .git)+ turn 级 sandboxPolicy 覆盖 read-only 重置 | 已实测(修过两轮才通) |
|
||||||
|
| 跨进程 state 竞态 | host-state.json 单文件,多 host.py 进程同时写会丢任务,同一进程内并行安全 | 已修:锁文件 + 保存时合并,实测双进程并发保存不丢 |
|
||||||
|
| 项目配置 | 每次都要传 model/repo/state 等参数 | 已修:host-config.json + init/config,CLI 优先于配置,密钥只走环境变量 |
|
||||||
|
| 定时自动化 | run-due 要手动跑,跑完没日志 | 已修:host-run.log 记录派发/跳过,automations --cron-line 生成 crontab 行,--install-cron 才装机 |
|
||||||
|
| 边界:cwd、沙箱、审批、模型 | dispatch / turn 参数里定,见 `config.example.toml` | 已实测 |
|
||||||
|
| 任务登记 | `host-state.json`:task -> thread/worktree/状态/diff;agent 状态本身仍在 CODEX_HOME,不另存第二份 | 已实测 |
|
||||||
|
|
||||||
|
**没对齐的部分(诚实说,不是偷工):**
|
||||||
|
|
||||||
|
- 云端派发、推送通知、Slack / GitHub 触发:那是 ChatGPT 托管服务,
|
||||||
|
开源里没有。`run-due` 要靠你自己的 cron 定时调,才等于“定时自动化”。
|
||||||
|
- GUI 的 inline 评论、side-by-side diff、点按钮批准:无 GUI 版
|
||||||
|
本来就不要这层,评审就是 git diff 文本 + merge/discard 命令。
|
||||||
|
- Computer Use:另一条管线,不在宿主编排里。
|
||||||
|
- 并行改同一文件:worktree 能防互相踩,但两个分支都在文件末尾
|
||||||
|
追加时,合并第二支仍可能冲突,host 会 abort 等人工解冲突,
|
||||||
|
不会自动猜合并结果——这和桌面版一样,是正确做法,不是 bug。
|
||||||
|
|
||||||
|
## 开源没给到的(别指望照搬)
|
||||||
|
|
||||||
|
- 模型本身、ChatGPT 登录、计费、Codex Cloud 云端沙箱。
|
||||||
|
- GitHub / Slack 等渠道的托管派发。
|
||||||
|
- 桌面版的 diff 评审 UI、多线程看板、通知。
|
||||||
|
- Computer Use(屏幕操作)是另一条管线,不在 CLI 中间层里;
|
||||||
|
本版 features 里虽然有 `computer_use` 开关,但那不是
|
||||||
|
“复制 app-server 就自动得到一个桌面操作 agent”。
|
||||||
|
|
||||||
|
## 安全
|
||||||
|
|
||||||
|
- 常驻服务不要用 `danger-full-access`,也不要把
|
||||||
|
`--dangerously-bypass-approvals-and-sandbox` 写进启动脚本,
|
||||||
|
那是给外部已经沙箱化的环境准备的。
|
||||||
|
- 审批策略默认:自动化用 `never` + `workspace-write` 或更严;
|
||||||
|
交互使用再用 `on-request`,由客户端(人)逐条裁决。
|
||||||
|
- `client.py --approval auto` 会放行所有审批请求,只用在
|
||||||
|
你完全信任的仓库和一次性任务里,默认是 deny。
|
||||||
|
- ws 传输对外暴露时务必配 `--ws-auth`,不要裸奔在公网。
|
||||||
|
|
||||||
|
## 本机实测记录(2026-10-25)
|
||||||
|
|
||||||
|
隔离 `CODEX_HOME`(不污染你真实的 `~/.codex`)下跑:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 client.py --codex-home /tmp/codex-client-test \
|
||||||
|
--cwd work/job-a --sandbox read-only --approval-policy never \
|
||||||
|
--prompt "只用一句话回答:2+2等于几?不要调用任何工具。"
|
||||||
|
```
|
||||||
|
|
||||||
|
结果:
|
||||||
|
|
||||||
|
- `initialize` 成功,返回 codexHome / platformOs。
|
||||||
|
- `thread/start` 成功,拿到 thread id,model 为默认模型。
|
||||||
|
- `turn/start` 成功,收到 `turn/started`、userMessage 事件。
|
||||||
|
- 模型调用处失败:`401 Unauthorized: Missing bearer token`,
|
||||||
|
随后 `turn/completed` 状态为 failed。
|
||||||
|
原因:隔离目录里没有任何登录/provider 密钥,且默认
|
||||||
|
provider 指向 api.openai.com。这是预期内的登录边界,
|
||||||
|
不是协议或客户端 bug。在你已登录的机器上同一条链路
|
||||||
|
会直接出结果,不需要改代码。
|
||||||
|
|
||||||
|
完整输出见 `TEST-REPORT.md`。
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
# 实测记录(2026-10-25,VM,codex-cli 0.164.0-alpha.9)
|
||||||
|
|
||||||
|
## 环境
|
||||||
|
|
||||||
|
- 本机已有官方 codex:`/opt/hatch-image/bin/codex`,`codex-cli 0.164.0-alpha.9`
|
||||||
|
- 无 cargo/rustc,2 核、可用内存约 0.5GB:从 Rust 源码现编不现实,
|
||||||
|
因此“照搬”走的是官方二进制自带的 app-server(即开源 core 编译产物),
|
||||||
|
而不是在本机重新编译一遍源码。协议 schema 同样由该二进制导出。
|
||||||
|
- `codex doctor`:除“active model provider auth env var is missing”外
|
||||||
|
其余健康;MCP 有可选配置问题(缺 env var 的 server)。
|
||||||
|
|
||||||
|
## 协议导出
|
||||||
|
|
||||||
|
- `codex app-server generate-json-schema --out …` 成功,导出 2.6MB,
|
||||||
|
本仓库只收了两个合并文件:`protocol/codex_app_server_protocol.schemas.json`
|
||||||
|
与 `protocol/codex_app_server_protocol.v2.schemas.json`。
|
||||||
|
- 从 v2 schema 确认的方法:initialize、initialized、model/list、
|
||||||
|
thread/start、thread/resume、thread/fork、thread/list、thread/archive、
|
||||||
|
thread/delete、turn/start、turn/steer、turn/interrupt、turn/completed。
|
||||||
|
- ThreadStartParams:cwd、model、modelProvider、sandbox、approvalPolicy、
|
||||||
|
approvalsReviewer、baseInstructions、developerInstructions 等。
|
||||||
|
- TurnStartParams:threadId、input(text)、model、effort、cwd、
|
||||||
|
parentTurnId、rootTurnId(委派链路用)、sandboxPolicy、approvalPolicy 等。
|
||||||
|
- features:`multi_agent` 为 stable/true,`multi_agent_v2` 为 stable/false。
|
||||||
|
|
||||||
|
## 客户端链路实测
|
||||||
|
|
||||||
|
隔离 CODEX_HOME 跑 client.py(见 README):
|
||||||
|
|
||||||
|
- initialize:成功。
|
||||||
|
- thread/start:成功,thread id 如 `01a17394-…`,cwd 正确落到 work/job-a。
|
||||||
|
- turn/start:成功,收到 turn/started 与 userMessage 事件。
|
||||||
|
- 模型调用:失败,`401 Unauthorized: Missing bearer token`,
|
||||||
|
重试 5 次后 turn/completed 状态 failed,耗时约 17 秒。
|
||||||
|
- 结论:协议、线程、回合、事件流、错误回推全部走通;
|
||||||
|
唯一缺的是模型登录,这一步在隔离目录里本就没有,
|
||||||
|
在已登录的机器上不需要改任何代码。
|
||||||
|
|
||||||
|
## 宿主编排实测(2026-10-25,host.py,中转站 gpt-6.1-sol,low 档位)
|
||||||
|
|
||||||
|
测试仓:`work/host-repo`,git 初始化,`sample.py` 只有 `hello()`。
|
||||||
|
隔离 CODEX_HOME(/tmp/codex-host-test,配了中转站 config),
|
||||||
|
配对二进制 `bin/codex`。
|
||||||
|
|
||||||
|
- **并行**:`parallel` 一次派 host-a(加 add2)和 host-b(加 mul2),
|
||||||
|
单个 app-server 进程内两个 thread 并发,约 70 秒双双 completed,
|
||||||
|
各自 worktree 里的 `sample.py` 正确,互不污染。
|
||||||
|
首跑曾因隔离目录缺中转站 config 而 401 到 api.openai.com,
|
||||||
|
补 config 后重跑通过——这是测试配置问题,不是 host 代码问题。
|
||||||
|
- **评审**:`review host-a` 给出 `M sample.py` + 完整 diff。
|
||||||
|
- **委派**:`delegate --parent-task host-a` 子任务只读检查 add2,
|
||||||
|
结论列了 NaN/inf、字符串拼接、TypeError 等边界,未改文件;
|
||||||
|
`--report-to-parent` 后主 thread 汇总了该结论。
|
||||||
|
- **fork / 续跑**:`fork host-a -> host-a-v2` 拿到新 thread id;
|
||||||
|
`run host-a` 续跑一回合“一句话说 add2 是干嘛的”成功。
|
||||||
|
- **目标循环**:`goal goal1` 目标是创建 `result.txt`(内容 goal-ok),
|
||||||
|
第 1 轮验收 `test -f result.txt` 通过,状态 goal-met。
|
||||||
|
- **合并**:`merge host-b` 先在 worktree 提交、再 `--no-ff` 合并,
|
||||||
|
主仓 `sample.py` 已含 mul2,worktree 已清理。
|
||||||
|
注意:merge 前必须先提交 worktree 改动,否则分支本身还是
|
||||||
|
旧 HEAD,合不到东西——这个 bug 在实测中发现并已修。
|
||||||
|
- **自动化**:`automations --add daily-check` + `run-due`,
|
||||||
|
到期任务自动派发并 completed,进评审队列。
|
||||||
|
- **状态**:`host-state.json` 当前任务:host-a completed、
|
||||||
|
host-b merged、host-a-review completed、host-a-v2 forked、
|
||||||
|
goal1 goal-met、daily-check-* completed。
|
||||||
|
|
||||||
|
## 宿主编排第二批对齐试验(2026-10-25 晚,中转站 gpt-6.1-sol,low 档位)
|
||||||
|
|
||||||
|
上一批测的是“能跑起来”,这一批测的是“边界和控制对不对齐”,
|
||||||
|
全部通过,只有一个协议坑被挖出来并已修成 host.py 的命令:
|
||||||
|
|
||||||
|
- **沙箱边界**:`--sandbox read-only` 的任务让 agent 建文件,
|
||||||
|
agent 回答“当前工作目录是只读文件系统”,文件确实没建出来。
|
||||||
|
边界不是摆设,是真拦住了。
|
||||||
|
- **审批 deny**:`--approval-policy untrusted --approval deny`,
|
||||||
|
agent 要跑 `echo approval-test-123`,宿主 deny 后 agent 明确说
|
||||||
|
“自动审批拒绝了”,没拿到输出。注意 host.py 里审批回答的
|
||||||
|
payload 是按实测反推的 `{"decision": "deny"}`,这条路是通的。
|
||||||
|
- **模型内子代理**:3 个子代理并行检查 sample.py,安全 / 质量 /
|
||||||
|
测试覆盖三份视角汇总回来,未改任何文件,结论还点名了主仓
|
||||||
|
已合并的 mul2 和 0% 测试覆盖——子代理不是走形式。
|
||||||
|
- **中断**:长诗任务跑了 5 秒后发 `turn/interrupt`,turn/completed
|
||||||
|
状态直接是 `interrupted`,不用等它写完。
|
||||||
|
- **compact**:直接对旧 thread 调 `thread/compact/start` 会报
|
||||||
|
`thread not found`,先 `thread/resume` 再 compact 就通了。
|
||||||
|
这是协议层面的先后顺序,host.py 已加 `compact` 命令,
|
||||||
|
内部就是 resume + compact,不让用户踩这个坑。
|
||||||
|
- **合并冲突**:conflict-a(hello-a)和 conflict-b(hello-b)改同一行,
|
||||||
|
先合 a 成功,主仓变 hello-a;再合 b 时 host 打印“合并冲突,
|
||||||
|
已 abort”,主仓仍是 hello-a 且干净,b 的 worktree 留着等人工解。
|
||||||
|
- **自动化幂等**:给 skip-test 设 interval 86400、last_run 为现在,
|
||||||
|
`run-due` 没派发它。同一轮里到期的 daily-check 被派发了,
|
||||||
|
但那次调用我没传中转站 key,turn 401 失败,已清理掉那个
|
||||||
|
误建任务和 worktree——这是测试调用失误,不是 host 逻辑问题。
|
||||||
|
|
||||||
|
## 宿主编排第三批对齐试验(2026-10-25 晚,ChatGPT 宿主能力)
|
||||||
|
|
||||||
|
这一批测的是 ChatGPT 宿主最有区分度的几件:宿主给工具、
|
||||||
|
宿主换模型、运行中改向、多任务并行上限、评审的未追踪文件、
|
||||||
|
以及失败时宿主是否稳得住。
|
||||||
|
|
||||||
|
- **宿主提供 MCP 工具(最重要的一条,带一个真修)**:
|
||||||
|
新写了 `examples/mcp_echo.py`(零依赖 stdio MCP,两个工具),
|
||||||
|
在隔离 CODEX_HOME 里注册为 `[mcp_servers.echo]`。
|
||||||
|
工具确实被 agent 发现了(工具名带 mcp__echo__ 前缀),但前两轮
|
||||||
|
都被审批拦住:第一轮 approval-policy=never 时直接报
|
||||||
|
“MCP tool call requires approval”;第二轮 untrusted + host 自动
|
||||||
|
放行时仍报 “user rejected MCP tool call”。
|
||||||
|
查 rollout 才找到根因:MCP 审批走的不是通用 approval,
|
||||||
|
而是 `mcpServer/elicitation/request`(mode=form),必须按
|
||||||
|
MCP elicitation 协议回 `action=accept/decline + content`,
|
||||||
|
回通用 `{"decision": ...}` 会被当成拒绝。
|
||||||
|
host.py 已按这个协议分流回答,第三轮 `mcp-test3` 成功:
|
||||||
|
agent 拿到了固定事实和 20+22=42,且未改文件。
|
||||||
|
结论:宿主给工具这条路是通的,但审批 payload 不能一刀切。
|
||||||
|
- **模型切换**:同一 thread host-a(原 gpt-6.1-sol)用
|
||||||
|
`--model gpt-6-sol` 续跑一回合,turn 的 model 字段确实是
|
||||||
|
gpt-6-sol,回答正确——宿主可以在 thread 内换模型。
|
||||||
|
- **运行中改向(steer)**:长诗任务跑 5 秒后发 `turn/steer`
|
||||||
|
(expectedTurnId 校验),turn 完成时已按新指令只剩 4 行短诗,
|
||||||
|
不是等旧任务跑完再开新回合。
|
||||||
|
- **3 并行**:host-c/div2、host-d/pow2、host-e/mod2 三个
|
||||||
|
worktree 同时跑,全部 completed,文件各自正确、互不污染。
|
||||||
|
- **未追踪文件评审**:`review goal1` 时 diff 为空,但 status
|
||||||
|
和 untracked 栏都列出了 result.txt——评审不能只看 diff,
|
||||||
|
未追踪文件要单独看,这一点 host.py 的 review 是对的。
|
||||||
|
- **失败不崩宿主**:用不存在的模型派发,task 状态为 failed,
|
||||||
|
错误是中转站 404(模型不可用),host.py 没崩、没卡住,
|
||||||
|
该任务的 worktree 和分支已清理。
|
||||||
|
- **未完成项**:另有一项仓库级上下文试验,其工具输出未通过
|
||||||
|
安全检查,本批报告不采用它的细节、不做结论;上面六项
|
||||||
|
不依赖它,结论独立成立。
|
||||||
|
|
||||||
|
## 生产级试验(2026-10-25 晚,真实项目仓,不是玩具 sample.py)
|
||||||
|
|
||||||
|
试验仓 `work/prod-repo`:core.py(add/sub)+ features/ 独立模块 +
|
||||||
|
tests/ 标准库 unittest,验收命令就是 `python3 -m unittest discover -s tests`。
|
||||||
|
起点 2 个测试,终点主仓 5 个功能、17 个测试全部通过。
|
||||||
|
|
||||||
|
- **并行生产**:prod-pow 和 prod-mod 各自建功能文件 + 测试文件,
|
||||||
|
不碰共享文件,并行完成,各自 worktree 4 个测试通过,互不污染。
|
||||||
|
- **目标循环(真实测试验收)**:prod-safe-div 1 轮通过,验收是
|
||||||
|
整个测试套件,不是“文件存在”。
|
||||||
|
- **评审闭环**:prod-parse 先实现了一个 naive 版本,委派只读评审
|
||||||
|
找出 5 条边界问题,汇总回主任务后,主任务修复为严格校验
|
||||||
|
(只接受 str、只接受 ASCII 十进制、空串抛错等),测试从 5 个
|
||||||
|
涨到 8 个,全部通过。评审任务本身没改任何文件。
|
||||||
|
- **合并链**:pow、mod、parse、safe-div、clamp 依次 `--no-ff`
|
||||||
|
合并进主仓,每次合并后主仓测试都跑过,最终 17 个测试 OK。
|
||||||
|
合并前评审时发现一个缺口并已修:新文件默认 untracked,
|
||||||
|
git diff 里看不到内容,review 现在会把未追踪文本文件内容
|
||||||
|
也打印出来,否则评审等于没看新文件。
|
||||||
|
- **自动化**:prod-nightly-tests(只读跑测试)run-due 派发完成,
|
||||||
|
紧接着再 run-due 一次全部跳过,幂等正确。
|
||||||
|
- **MCP(生产仓)**:prod-mcp 在生产仓 worktree 里调用宿主 MCP,
|
||||||
|
拿到固定事实和 7+8=15,未改文件。
|
||||||
|
- **失败与恢复(生产级真正挖出来的东西)**:
|
||||||
|
1. 委派评审第一轮因 shell 超时 300 秒被杀,turn_aborted,
|
||||||
|
且任务登记只在 turn 完成后才落盘,等于任务丢了。
|
||||||
|
已修:dispatch / delegate / goal / run-due 都先落盘再跑 turn,
|
||||||
|
中断后可用 run 续跑。
|
||||||
|
2. fork 换 worktree 后沙箱被服务端重置为 read-only,fork 时传
|
||||||
|
sandbox 参数不认;同 cwd fork 则保留 workspace-write。
|
||||||
|
已修:turn 级 sandboxPolicy 会覆盖 thread 级设置,run 时按
|
||||||
|
任务沙箱传,fork 隔离 worktree + 写操作已验证通过。
|
||||||
|
3. fork 拷工作区文件时把 .git 文件也拷过去了,新 worktree
|
||||||
|
的指针指回旧 worktree,git worktree 校验失败。
|
||||||
|
已修:拷文件时跳过 .git,已坏的 v2 清理、v3 指针已修复。
|
||||||
|
4. 目标循环第一版只说“验收未通过”,不告诉 agent 错在哪,
|
||||||
|
prod-retry 3 轮原地不动(测试数一直是 1)。
|
||||||
|
已修:目标循环把验收命令和失败输出喂给下一轮,且验收命令
|
||||||
|
本身要打印诊断信息(如 test_count=1, need >=3),
|
||||||
|
prod-retry2 第 1 轮失败、第 2 轮补到 3 个测试、目标达成。
|
||||||
|
- **生产级结论**:本地、无 GUI 的宿主编排,ChatGPT 宿主在
|
||||||
|
本地能做的那部分,基本对齐了——并行隔离、测试验收、评审门、
|
||||||
|
委派修复闭环、合并链、自动化、MCP、模型切换、改向/中断、
|
||||||
|
失败恢复,都在真实项目上跑通了。
|
||||||
|
没对齐的仍然是云端和界面那部分:云端派发、推送通知、
|
||||||
|
GitHub/Slack 触发、CI/PR 集成、GUI inline 评论、Computer Use,
|
||||||
|
以及一个已知局限:host-state.json 是单文件,多 host.py
|
||||||
|
进程同时写会有 read-modify-write 竞态,同一进程内的并行
|
||||||
|
是安全的,跨进程并行没有锁,不要两个 host.py 进程同时跑
|
||||||
|
同一个 state 文件。
|
||||||
|
|
||||||
|
## 生产加固(2026-10-26,state 锁 / 项目配置 / 定时自动化)
|
||||||
|
|
||||||
|
- **state 锁**:`host-state.json.lock` + fcntl 锁,锁只包住读/写
|
||||||
|
短临界区,不包整个模型 turn;保存时在锁里重读磁盘,按任务名
|
||||||
|
覆盖、按自动化名合并,tmp 文件带 pid。
|
||||||
|
实测:两个进程同时加载、睡 0.5 秒、各自加一个任务再保存,
|
||||||
|
最终两个任务都在,没有合并逻辑的话后保存的会把先保存的覆盖掉。
|
||||||
|
- **项目配置**:`host.py init` 生成 `host-config.json`(repo、
|
||||||
|
model、effort、沙箱、审批、codex_home、state,无密钥),
|
||||||
|
`host.py config` 打印有效配置,优先级 CLI > 配置 > 内置默认;
|
||||||
|
dispatch/goal 不传 --repo 时用配置里的 repo,没配则明确报错。
|
||||||
|
实测:/tmp 测试配置 init、config、repo 缺省路径都通;
|
||||||
|
项目里默认没有 host-config.json,旧调用方式不受影响。
|
||||||
|
- **定时自动化**:run-due 现在写 `host-run.log`(派发和跳过都记),
|
||||||
|
`automations --cron-line` 只打印 crontab 行,不装机;
|
||||||
|
`automations --install-cron` 才真正写 crontab 且幂等。
|
||||||
|
实测:未到期的自动化 run-due 输出为空,但日志里有一条 skip;
|
||||||
|
cron 行已验证格式正确,本次没有实际装机——装机后模型任务
|
||||||
|
会自动跑、花中转站额度,所以装机这一步留给用户显式下令。
|
||||||
|
另注意:VM 会被平台回收,crontab 不一定活过回收,常驻机器
|
||||||
|
用 crontab,VM 上更稳的是 Muse cron 定时调 run-due。
|
||||||
|
- **未修 / 已知局限**:锁只能防同一台机器上的多进程竞态,
|
||||||
|
不能防两台机器同时写同一个共享 state 文件;定时任务本身
|
||||||
|
仍需外部调度器(crontab 或 Muse cron)触发,host.py 自己
|
||||||
|
不常驻。
|
||||||
|
|
||||||
|
## 补充:原始协议探针(同一晚,隔离目录)
|
||||||
|
|
||||||
|
- `model/list` 成功:返回 `gpt-6.1-sol`,推理档位 low/medium/high/
|
||||||
|
xhigh/max/ultra,`multiAgentVersion` 为 `v2`。
|
||||||
|
- `thread/list` 在隔离目录下为空,符合预期(不污染真实线程)。
|
||||||
|
- 附带警告(不影响核心链路):本机缺 bubblewrap 时用内置兜底;
|
||||||
|
Code Mode 因缺 `codex-code-mode-host` 可执行文件而 fail closed,
|
||||||
|
需该功能时要另装该组件。
|
||||||
|
|
||||||
|
## 工具执行修复(2026-10-25,用户选“先修好工具执行问题”后)
|
||||||
|
|
||||||
|
根因是两层缺件,不是中转站问题,rollout 日志里有明文证据:
|
||||||
|
|
||||||
|
1. **缺 code-mode host**:系统里的 codex 是裸二进制,
|
||||||
|
`/opt/hatch-image/bin` 是只读挂载,旁边没有
|
||||||
|
`codex-code-mode-host`,app-server 报
|
||||||
|
“Code Mode is unavailable … host executable was not found”,
|
||||||
|
工具 fail closed,agent 称“写入工具不可用”。
|
||||||
|
修复:从官方 npm 平台包
|
||||||
|
`@openai/codex@0.164.0-alpha.9-linux-x64` 里取出同版本
|
||||||
|
`codex-code-mode-host`(73MB),与 codex 放到同一目录。
|
||||||
|
注意:host 只认真实可执行文件旁边的路径,放 PATH 里没用;
|
||||||
|
系统目录只读,所以配对目录只能放 workspace,不能直接补系统目录。
|
||||||
|
2. **缺 bwrap**:配对后工具能启动了,但写文件仍 3 次 instant failed,
|
||||||
|
rollout 里 panic 明文是
|
||||||
|
“bubblewrap is unavailable: no system bwrap was found on PATH
|
||||||
|
and no bundled codex-resources/bwrap binary was found
|
||||||
|
next to the Codex executable”。
|
||||||
|
修复:把系统目录里的 `codex-resources/bwrap` 也复制到配对目录。
|
||||||
|
|
||||||
|
修复后配对目录:`bin/codex`(293MB)+
|
||||||
|
`bin/codex-code-mode-host`(74MB)+
|
||||||
|
`bin/codex-resources/bwrap`(502KB),合计约 366MB。
|
||||||
|
这是本机 VM 的补丁,Mac 上 npm 正常安装自带这三件套,不需要它;
|
||||||
|
`start.sh` 已改成优先用 `bin/codex`,`client.py` 也支持
|
||||||
|
`CODEX_BIN` 环境变量指定配对二进制。
|
||||||
|
|
||||||
|
修复后实测(中转站,gpt-6.1-sol,effort low,workspace-write):
|
||||||
|
|
||||||
|
- 回合 10.3 秒完成,状态 completed,不是 failed。
|
||||||
|
- `commandExecution` 状态 completed,命令是
|
||||||
|
`printf '%s\n' 'codex-ok' > test-result.txt && cat test-result.txt`。
|
||||||
|
- 落盘验证:`work/xcm-test/test-result.txt` 内容确为 `codex-ok`。
|
||||||
|
- 此前失败的两个版本(max 60 秒、配对后无 bwrap 113 秒)均未建文件,
|
||||||
|
对照说明修的是执行环境,不是 prompt 或模型问题。
|
||||||
|
|
||||||
|
## 未实测
|
||||||
|
|
||||||
|
- 真实模型出结果的一回合(缺登录,VM 里不放密钥)。——已过期:
|
||||||
|
上面中转站实测已覆盖,见“工具执行修复”一节。
|
||||||
|
- ws / unix 传输的多客户端并发(start.sh 已把参数透传给官方
|
||||||
|
app-server,传输层本身是官方实现的,未在本机多客户端压测)。
|
||||||
|
- 审批请求的完整回包字段:各版本间字段有差异,client.py 默认
|
||||||
|
用 never 绕开审批;on-request + auto/deny 的回包是尽力而为,
|
||||||
|
生产交互客户端应以本版 protocol schema 为准再对齐一次。
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Codex app-server 客户端(stdio JSON-RPC,零第三方依赖)。
|
||||||
|
|
||||||
|
照搬的是官方 codex 二进制自带的 app-server:本文件不实现 agent 循环、
|
||||||
|
不实现沙箱、不实现工具,只做一件事——按官方协议跟 app-server 对话:
|
||||||
|
initialize -> initialized -> thread/start -> turn/start
|
||||||
|
-> 流式事件(agent 文本、工具调用、审批请求)-> turn/completed
|
||||||
|
|
||||||
|
协议定义见 protocol/codex_app_server_protocol.v2.schemas.json,
|
||||||
|
由 `codex app-server generate-json-schema` 从官方二进制原样导出。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 client.py --prompt "修掉 README 里的错别字"
|
||||||
|
python3 client.py --prompt "..." --cwd /path/to/repo --model gpt-5.x
|
||||||
|
python3 client.py --list-threads
|
||||||
|
python3 client.py --thread-id <id> --prompt "继续"
|
||||||
|
python3 client.py --prompt "..." --approval-policy on-request --approval deny
|
||||||
|
python3 client.py --prompt "..." --sandbox read-only
|
||||||
|
|
||||||
|
审批:默认 --approval-policy never,服务端不发审批请求,适合自动化。
|
||||||
|
若显式用 on-request,--approval auto 会放行、deny 会拒绝(默认 deny)。
|
||||||
|
注意:子 agent 的审批/沙箱继承父 thread,这里不做额外权限边界,
|
||||||
|
见 README“子代理”一节。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
CODEX = os.environ.get("CODEX_BIN", "codex")
|
||||||
|
|
||||||
|
|
||||||
|
class AppServer:
|
||||||
|
def __init__(self, codex_home=None, extra_args=None):
|
||||||
|
env = dict(os.environ)
|
||||||
|
if codex_home:
|
||||||
|
os.makedirs(codex_home, exist_ok=True)
|
||||||
|
env["CODEX_HOME"] = codex_home
|
||||||
|
cmd = [CODEX, "app-server"]
|
||||||
|
if extra_args:
|
||||||
|
cmd += extra_args
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL, env=env,
|
||||||
|
)
|
||||||
|
self.buf = b""
|
||||||
|
self.next_id = 1
|
||||||
|
self.pending = {} # id -> message
|
||||||
|
self.approval_policy = "deny"
|
||||||
|
|
||||||
|
def send(self, obj):
|
||||||
|
line = json.dumps(obj) + "\n"
|
||||||
|
self.proc.stdin.write(line.encode())
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
return obj.get("id")
|
||||||
|
|
||||||
|
def request(self, method, params=None):
|
||||||
|
rid = self.next_id
|
||||||
|
self.next_id += 1
|
||||||
|
self.send({"jsonrpc": "2.0", "id": rid, "method": method,
|
||||||
|
"params": params or {}})
|
||||||
|
return rid
|
||||||
|
|
||||||
|
def notify(self, method, params=None):
|
||||||
|
self.send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
||||||
|
|
||||||
|
def _read_msg(self, timeout):
|
||||||
|
"""读一条服务端消息;是服务端请求时先回包,再继续读。"""
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
while b"\n" in self.buf:
|
||||||
|
line, self.buf = self.buf.split(b"\n", 1)
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
msg = json.loads(line)
|
||||||
|
if "method" in msg and "id" in msg:
|
||||||
|
self._answer_server_request(msg)
|
||||||
|
continue
|
||||||
|
return msg
|
||||||
|
r, _, _ = select.select([self.proc.stdout], [], [], 0.2)
|
||||||
|
if r:
|
||||||
|
chunk = os.read(self.proc.stdout.fileno(), 65536)
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
self.buf += chunk
|
||||||
|
if self.proc.poll() is not None:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _answer_server_request(self, msg):
|
||||||
|
method = msg.get("method", "")
|
||||||
|
# 审批类请求:按策略回包。不同审批方法的回包字段在各版本间有差异,
|
||||||
|
# 这里用最常见的 decision 字段;若服务端不认,会在事件里看到错误,
|
||||||
|
# 此时应改回 approval-policy=never 跑自动化,或在 README 的审批表里查本版字段。
|
||||||
|
decision = "approve" if self.approval_policy == "auto" else "deny"
|
||||||
|
payload = {"decision": decision}
|
||||||
|
if "approval" in method.lower() or "permission" in method.lower():
|
||||||
|
print(f"[server-request] {method} -> {decision} "
|
||||||
|
f"(params={json.dumps(msg.get('params', {}))[:400]})",
|
||||||
|
file=sys.stderr)
|
||||||
|
self.send({"jsonrpc": "2.0", "id": msg["id"], "result": payload})
|
||||||
|
|
||||||
|
def wait_for(self, rid, timeout=600, on_event=None):
|
||||||
|
"""等指定 id 的响应;途中的通知/事件交给 on_event 打印。"""
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
msg = self._read_msg(max(1, end - time.time()))
|
||||||
|
if msg is None:
|
||||||
|
break
|
||||||
|
if msg.get("id") == rid:
|
||||||
|
return msg
|
||||||
|
if on_event:
|
||||||
|
on_event(msg)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
rid = self.request("initialize", {
|
||||||
|
"clientInfo": {"name": "codex-app-server-client", "version": "0.1"},
|
||||||
|
})
|
||||||
|
resp = self.wait_for(rid, timeout=60)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
raise RuntimeError(f"initialize 失败: {resp}")
|
||||||
|
self.notify("initialized", {})
|
||||||
|
return resp["result"]
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.proc.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
self.proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_event(msg):
|
||||||
|
method = msg.get("method", "")
|
||||||
|
params = msg.get("params", {}) or {}
|
||||||
|
# 文本增量:不同版本的字段名略有差异,尽量都覆盖
|
||||||
|
if method in ("thread/item/updated", "item/updated", "agent/message/delta",
|
||||||
|
"thread/item/started", "thread/item/completed"):
|
||||||
|
item = params.get("item") or params
|
||||||
|
text = item.get("text") or item.get("delta") or ""
|
||||||
|
if not text and isinstance(item.get("content"), list):
|
||||||
|
text = "".join(c.get("text", "") for c in item["content"]
|
||||||
|
if isinstance(c, dict))
|
||||||
|
if text and item.get("type") in ("agentMessage", None, "agent_message"):
|
||||||
|
print(text, end="", flush=True)
|
||||||
|
elif method == "turn/completed":
|
||||||
|
print(f"\n[turn/completed] {json.dumps(params)[:800]}")
|
||||||
|
elif "completed" in method or "started" in method:
|
||||||
|
print(f"\n[{method}] {json.dumps(params)[:500]}", file=sys.stderr)
|
||||||
|
elif "error" in str(params).lower():
|
||||||
|
print(f"\n[{method}] {json.dumps(params)[:800]}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--prompt", help="本回合发给 agent 的文本")
|
||||||
|
ap.add_argument("--cwd", default=os.getcwd())
|
||||||
|
ap.add_argument("--model", default=None)
|
||||||
|
ap.add_argument("--sandbox", default="workspace-write",
|
||||||
|
choices=["read-only", "workspace-write", "danger-full-access"])
|
||||||
|
ap.add_argument("--approval-policy", default="never",
|
||||||
|
choices=["never", "on-request", "untrusted"])
|
||||||
|
ap.add_argument("--approval", default="deny", choices=["auto", "deny"])
|
||||||
|
ap.add_argument("--effort", default=None,
|
||||||
|
help="本回合推理档位,如 low/medium/high/xhigh/max/ultra")
|
||||||
|
ap.add_argument("--thread-id", default=None, help="续跑已有 thread,不传则新建")
|
||||||
|
ap.add_argument("--list-threads", action="store_true")
|
||||||
|
ap.add_argument("--codex-home", default=None,
|
||||||
|
help="默认用 ~/.codex;测试时可指向空目录隔离")
|
||||||
|
ap.add_argument("--timeout", type=int, default=600)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
srv = AppServer(codex_home=args.codex_home)
|
||||||
|
srv.approval_policy = args.approval
|
||||||
|
try:
|
||||||
|
info = srv.initialize()
|
||||||
|
print(f"[initialize] codexHome={info.get('codexHome')} "
|
||||||
|
f"platform={info.get('platformOs')}", file=sys.stderr)
|
||||||
|
|
||||||
|
if args.list_threads:
|
||||||
|
rid = srv.request("thread/list", {})
|
||||||
|
print(json.dumps(srv.wait_for(rid, timeout=60), indent=2,
|
||||||
|
ensure_ascii=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.thread_id:
|
||||||
|
rid = srv.request("thread/resume", {"threadId": args.thread_id})
|
||||||
|
resp = srv.wait_for(rid, timeout=60)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
print(f"thread/resume 失败: {resp}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
thread_id = args.thread_id
|
||||||
|
print(f"[thread/resumed] {thread_id}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
params = {"cwd": os.path.abspath(args.cwd),
|
||||||
|
"approvalPolicy": args.approval_policy,
|
||||||
|
"sandbox": args.sandbox}
|
||||||
|
if args.model:
|
||||||
|
params["model"] = args.model
|
||||||
|
rid = srv.request("thread/start", params)
|
||||||
|
resp = srv.wait_for(rid, timeout=60)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
print(f"thread/start 失败: {resp}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
thread = resp["result"]["thread"]
|
||||||
|
thread_id = thread["id"]
|
||||||
|
print(f"[thread/started] {thread_id} "
|
||||||
|
f"model={resp['result'].get('model')}", file=sys.stderr)
|
||||||
|
|
||||||
|
if not args.prompt:
|
||||||
|
print(f"thread_id={thread_id}")
|
||||||
|
return
|
||||||
|
turn_params = {
|
||||||
|
"threadId": thread_id,
|
||||||
|
"input": [{"type": "text", "text": args.prompt}],
|
||||||
|
}
|
||||||
|
if args.model:
|
||||||
|
turn_params["model"] = args.model
|
||||||
|
if args.effort:
|
||||||
|
turn_params["effort"] = args.effort
|
||||||
|
rid = srv.request("turn/start", turn_params)
|
||||||
|
# turn/start 的响应本身不代表完成;完成以 turn/completed 通知为准
|
||||||
|
print("[turn/started] 等待 turn/completed …", file=sys.stderr)
|
||||||
|
end = time.time() + args.timeout
|
||||||
|
while time.time() < end:
|
||||||
|
msg = srv._read_msg(max(1, end - time.time()))
|
||||||
|
if msg is None:
|
||||||
|
break
|
||||||
|
if msg.get("id") == rid and "error" in msg:
|
||||||
|
print(f"turn/start 出错: {msg}", file=sys.stderr)
|
||||||
|
break
|
||||||
|
if msg.get("method") == "turn/completed":
|
||||||
|
print(f"\n[done] {json.dumps(msg.get('params', {}))[:1000]}")
|
||||||
|
break
|
||||||
|
handle_event(msg)
|
||||||
|
finally:
|
||||||
|
srv.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Codex app-server 配置示例 —— 复制到 ~/.codex/config.toml 再按需改。
|
||||||
|
# 这里只放开源侧通用的项;你自己的登录/provider/密钥不要写进仓库。
|
||||||
|
# 完整字段以 `codex app-server generate-json-schema` 导出的协议为准。
|
||||||
|
|
||||||
|
# 模型:不填则用 codex 默认推荐模型;想换模型改这一行。
|
||||||
|
# model = "gpt-5.x"
|
||||||
|
|
||||||
|
# 沙箱 + 审批:自动化默认收紧,交互使用时再放宽。
|
||||||
|
# sandbox_mode 可选 read-only / workspace-write / danger-full-access
|
||||||
|
# approval_policy 可选 untrusted / on-request / never
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
approval_policy = "on-request"
|
||||||
|
|
||||||
|
# 子代理:本版里 multi_agent 已是 stable,默认开启。
|
||||||
|
# 想要主 agent 派生 worker/explorer 并行跑,不用额外开关,
|
||||||
|
# 在 prompt 里明确说“用 N 个子代理并行做 …,最后汇总”即可。
|
||||||
|
# [features]
|
||||||
|
# multi_agent = true
|
||||||
|
|
||||||
|
# MCP:给 agent 接外部工具(数据库、内部 API 等),按需取消注释。
|
||||||
|
# [mcp_servers.my-tool]
|
||||||
|
# command = "npx"
|
||||||
|
# args = ["-y", "my-mcp-server"]
|
||||||
|
# startup_timeout_sec = 30
|
||||||
|
# 注意:MCP 工具同样走上面同一套沙箱/审批,不会绕过。
|
||||||
|
|
||||||
|
# Skills / 插件:放 ~/.codex/skills 或用 `codex plugin` 管理,
|
||||||
|
# app-server 会在 thread 里按需加载,不用在代码里硬编码。
|
||||||
|
|
||||||
|
# 项目指令:每个仓库根放 AGENTS.md,thread 启动时自动合并读取,
|
||||||
|
# 依次是 ~/.codex/AGENTS.md、仓库根、当前子目录,越往下优先级越高。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 中转站配置示例(无密钥版)——复制到你的 CODEX_HOME/config.toml 再用。
|
||||||
|
# 密钥不要写进这个文件:用环境变量传给 codex 进程,例如
|
||||||
|
# XCM_API_KEY=... codex app-server
|
||||||
|
# 或在你自己的机器上把密钥放进 shell 环境 / 密钥管理工具。
|
||||||
|
# 本文件只记录 provider 形状,值本身不在仓库里。
|
||||||
|
|
||||||
|
model = "gpt-6.1-sol"
|
||||||
|
model_provider = "xcmapi"
|
||||||
|
|
||||||
|
[model_providers.xcmapi]
|
||||||
|
name = "XCM Relay"
|
||||||
|
base_url = "https://xcmapi.org/v1"
|
||||||
|
env_key = "XCM_API_KEY"
|
||||||
|
wire_api = "responses"
|
||||||
|
|
||||||
|
# 实测(2026-10-25):/v1/models 返回 5 个模型,含 gpt-6.1-sol;
|
||||||
|
# app-server 回合用 gpt-6.1-sol + effort low 已跑通写文件,
|
||||||
|
# 详见 TEST-REPORT.md。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"name":"host-c","repo":"work/host-repo","task":"在 sample.py 里加一个函数 div2(a, b),返回 a/b,不要改其他函数,不要做其他改动。"},{"name":"host-d","repo":"work/host-repo","task":"在 sample.py 里加一个函数 pow2(a, b),返回 a**b,不要改其他函数,不要做其他改动。"},{"name":"host-e","repo":"work/host-repo","task":"在 sample.py 里加一个函数 mod2(a, b),返回 a%b,不要改其他函数,不要做其他改动。"}]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"name":"host-a","repo":"work/host-repo","task":"在 sample.py 里加一个函数 add2(a, b),返回 a+b,不要改 hello,不要做其他改动。"},{"name":"host-b","repo":"work/host-repo","task":"在 sample.py 里加一个函数 mul2(a, b),返回 a*b,不要改 hello,不要做其他改动。"}]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"cwd":"work/job-a","prompt":"只读审查 work/job-a 里的代码,列出最严重的 3 个问题,不要改任何文件。","sandbox":"read-only"},{"cwd":"work/job-b","prompt":"只读审查 work/job-b 里的代码,列出最严重的 3 个问题,不要改任何文件。","sandbox":"read-only"}]
|
||||||
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""最小 MCP stdio 服务器(宿主提供工具的对齐试验用)。
|
||||||
|
|
||||||
|
只给两个工具,不依赖任何第三方包:
|
||||||
|
echo_fact:返回固定事实字符串 mcp-fact-42
|
||||||
|
add:返回 a + b
|
||||||
|
|
||||||
|
协议:JSON-RPC over stdio,MCP initialize / tools/list / tools/call,
|
||||||
|
以及 notifications/initialized、ping。codex 的 MCP 客户端就是按
|
||||||
|
这个协议调它的,宿主只要在 config.toml 里写:
|
||||||
|
|
||||||
|
[mcp_servers.echo]
|
||||||
|
command = "python3"
|
||||||
|
args = ["<本文件绝对路径>"]
|
||||||
|
startup_timeout_sec = 30
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def send(obj):
|
||||||
|
sys.stdout.write(json.dumps(obj) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
for line in sys.stdin:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
req = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
method = req.get("method")
|
||||||
|
rid = req.get("id")
|
||||||
|
# 通知:没有 id,不回
|
||||||
|
if method in ("notifications/initialized", "notifications/cancelled"):
|
||||||
|
continue
|
||||||
|
if method == "initialize":
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "echo", "version": "0.1"}}})
|
||||||
|
elif method == "tools/list":
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {"tools": [
|
||||||
|
{"name": "echo_fact",
|
||||||
|
"description": "返回一个固定事实字符串,用于验证宿主提供的 MCP 工具是否被 agent 调用",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "add",
|
||||||
|
"description": "返回 a + b",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"a": {"type": "number"},
|
||||||
|
"b": {"type": "number"}},
|
||||||
|
"required": ["a", "b"]}}]}})
|
||||||
|
elif method == "tools/call":
|
||||||
|
params = req.get("params") or {}
|
||||||
|
name = params.get("name")
|
||||||
|
args = params.get("arguments") or {}
|
||||||
|
if name == "echo_fact":
|
||||||
|
text = "mcp-fact-42"
|
||||||
|
elif name == "add":
|
||||||
|
text = str(args.get("a", 0) + args.get("b", 0))
|
||||||
|
else:
|
||||||
|
text = f"unknown tool: {name}"
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||||
|
"content": [{"type": "text", "text": text}]}})
|
||||||
|
elif method == "ping":
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
elif rid is not None:
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"name":"prod-pow","repo":"work/prod-repo","task":"在 features/ 新建 pow_feature.py,实现 pow_calc(a, b) 返回 a**b;在 tests/ 新建 test_pow.py,用 unittest 覆盖正常值和 0 次幂;跑 python3 -m unittest discover -s tests -v,全部通过才算完成。不要改 core.py、tests/test_core.py 和其他功能文件。"},{"name":"prod-mod","repo":"work/prod-repo","task":"在 features/ 新建 mod_feature.py,实现 mod_calc(a, b) 返回 a%b,b 为 0 时抛出 ValueError;在 tests/ 新建 test_mod.py,用 unittest 覆盖正常值和 b=0 抛错;跑 python3 -m unittest discover -s tests -v,全部通过才算完成。不要改 core.py、tests/test_core.py 和其他功能文件。"}]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
用 3 个子代理并行做代码评审,最后由你汇总成一份结论,不要直接改代码:
|
||||||
|
|
||||||
|
1. 安全子代理:只看鉴权、注入、敏感信息泄露。
|
||||||
|
2. 质量子代理:只看可读性、重复代码、边界条件。
|
||||||
|
3. 测试子代理:只看现有测试是否覆盖了本次改动,缺什么用例。
|
||||||
|
|
||||||
|
要求:每个子代理的结果先分开列出,再给一份合并结论;
|
||||||
|
只列有证据的项(文件名+行号/函数名),猜测的标明是猜测;
|
||||||
|
汇总时去掉重复项,按严重程度排序。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_note":"复制成 host-config.json 再改;API key 绝不放这里,只走环境变量","approval":"deny","approval_policy":"never","codex_home":"","effort":"low","model":"gpt-6.1-sol","repo":"work/prod-repo","sandbox":"workspace-write","state":"host-state.json"}
|
||||||
@@ -0,0 +1,869 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
宿主编排层(无 GUI):对齐 ChatGPT / Codex 桌面版宿主该干的事,
|
||||||
|
而不是再写一个 agent 循环。
|
||||||
|
|
||||||
|
ChatGPT / Codex 桌面版作为宿主,核心就这几件(见 README 的对齐表):
|
||||||
|
1. 线程生命周期:建、续跑、fork、归档、删除、列表、持久化
|
||||||
|
2. 并行派发:多 thread 同时跑,每任务一个 git worktree 隔离
|
||||||
|
3. 子代理委派:主 thread 派子 thread,子 thread 结果汇总回主 thread
|
||||||
|
(模型内子代理是另一层,见 examples/subagent-prompt.txt;
|
||||||
|
这里是宿主级委派,用 parentTurnId / rootTurnId 把委派链串起来)
|
||||||
|
4. 长任务 / 目标循环:同一 thread 多回合,直到验收命令通过或到上限
|
||||||
|
5. 评审队列:diff + 状态 + 合并 / 丢弃,人工在合并前把关
|
||||||
|
6. 自动化:按间隔到期的任务自动派发,结果进评审队列(无推送,
|
||||||
|
这是云端 ChatGPT 才有的部分,见 README 未对齐表)
|
||||||
|
7. 边界:宿主定 cwd、沙箱、审批、模型、MCP/skills,agent 只在边界内跑
|
||||||
|
|
||||||
|
实现方式:一个 app-server 进程托管多个 thread(官方 thread manager
|
||||||
|
就是这么设计的),本文件用后台读线程按 id / threadId 分发响应和
|
||||||
|
turn/completed 通知,所以多个 thread 的 turn 可以并发跑。
|
||||||
|
线程状态本身持久化在 CODEX_HOME,host-state.json 只存宿主自己的
|
||||||
|
任务登记(task -> thread/worktree/状态/diff),不是 agent 状态的
|
||||||
|
第二份拷贝。
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
python3 host.py dispatch --repo work/host-repo --task "给 sample.py 加 add2 函数" --name add2
|
||||||
|
python3 host.py list
|
||||||
|
python3 host.py status add2
|
||||||
|
python3 host.py review add2
|
||||||
|
python3 host.py merge add2 # 评审通过后才合并,冲突会 abort
|
||||||
|
python3 host.py discard add2 # 评审不通过,删 worktree 和分支
|
||||||
|
python3 host.py run --task add2 --prompt "再补一个测试"
|
||||||
|
python3 host.py fork --task add2 --name add2-v2
|
||||||
|
python3 host.py delegate --parent-task add2 --task "只读检查 add2 有没有边界问题" --name add2-review
|
||||||
|
python3 host.py goal --repo work/host-repo --task "创建 result.txt,内容只有一行 goal-ok" \
|
||||||
|
--verify "test -f result.txt" --name goal1
|
||||||
|
python3 host.py parallel --jobs examples/host-jobs.json
|
||||||
|
python3 host.py automations --add --name daily-check --repo work/host-repo \
|
||||||
|
--task "只读检查 sample.py 有没有明显问题" --interval 86400
|
||||||
|
python3 host.py run-due
|
||||||
|
|
||||||
|
默认 CODEX_HOME 是 ~/.codex;测试时用 --codex-home 指向隔离目录。
|
||||||
|
默认用 PATH 里的 codex;本机 VM 需用配对二进制时设
|
||||||
|
CODEX_BIN=.../bin/codex(start.sh 已自动优先用 bin/codex)。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_STATE = os.path.join(HERE, "host-state.json")
|
||||||
|
|
||||||
|
|
||||||
|
def default_codex_bin():
|
||||||
|
if os.environ.get("CODEX_BIN"):
|
||||||
|
return os.environ["CODEX_BIN"]
|
||||||
|
paired = os.path.join(HERE, "bin", "codex")
|
||||||
|
if os.path.exists(paired):
|
||||||
|
return paired
|
||||||
|
return "codex"
|
||||||
|
|
||||||
|
|
||||||
|
class Server:
|
||||||
|
"""单个 app-server 进程,多 thread 并发。"""
|
||||||
|
|
||||||
|
def __init__(self, codex_bin=None, codex_home=None, approval="deny"):
|
||||||
|
self.codex_bin = codex_bin or default_codex_bin()
|
||||||
|
env = dict(os.environ)
|
||||||
|
if codex_home:
|
||||||
|
os.makedirs(codex_home, exist_ok=True)
|
||||||
|
env["CODEX_HOME"] = codex_home
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[self.codex_bin, "app-server"],
|
||||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL, env=env,
|
||||||
|
)
|
||||||
|
self.send_lock = threading.Lock()
|
||||||
|
self.next_id = 1
|
||||||
|
self.pending = {}
|
||||||
|
self.pending_lock = threading.Lock()
|
||||||
|
self.turn_queues = {}
|
||||||
|
self.events = []
|
||||||
|
self.events_lock = threading.Lock()
|
||||||
|
self.approval = approval
|
||||||
|
self.reader = threading.Thread(target=self._read_loop, daemon=True)
|
||||||
|
self.reader.start()
|
||||||
|
|
||||||
|
def _answer_server_request(self, msg):
|
||||||
|
# MCP 工具审批不是通用 approval:服务端发的是
|
||||||
|
# mcpServer/elicitation/request(mode=form,requestedSchema),
|
||||||
|
# 按 MCP elicitation 协议回 action=accept/decline + content,
|
||||||
|
# 回通用 {"decision": ...} 会被当成拒绝(2026-10-25 实测)。
|
||||||
|
if msg.get("method") == "mcpServer/elicitation/request":
|
||||||
|
if self.approval == "auto":
|
||||||
|
result = {"action": "accept", "content": {}}
|
||||||
|
else:
|
||||||
|
result = {"action": "decline"}
|
||||||
|
self._send({"jsonrpc": "2.0", "id": msg["id"], "result": result})
|
||||||
|
return
|
||||||
|
decision = "approve" if self.approval == "auto" else "deny"
|
||||||
|
self._send({"jsonrpc": "2.0", "id": msg["id"],
|
||||||
|
"result": {"decision": decision}})
|
||||||
|
|
||||||
|
def _read_loop(self):
|
||||||
|
try:
|
||||||
|
for raw in self.proc.stdout:
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if "method" in msg and "id" in msg:
|
||||||
|
self._answer_server_request(msg)
|
||||||
|
continue
|
||||||
|
if "id" in msg:
|
||||||
|
with self.pending_lock:
|
||||||
|
q = self.pending.get(msg["id"])
|
||||||
|
if q is not None:
|
||||||
|
q.put(msg)
|
||||||
|
continue
|
||||||
|
# 通知
|
||||||
|
params = msg.get("params") or {}
|
||||||
|
thread_id = params.get("threadId")
|
||||||
|
if not thread_id and isinstance(params.get("thread"), dict):
|
||||||
|
thread_id = params["thread"].get("id")
|
||||||
|
with self.events_lock:
|
||||||
|
self.events.append(msg)
|
||||||
|
if msg.get("method") == "turn/completed" and thread_id:
|
||||||
|
q = self.turn_queues.get(thread_id)
|
||||||
|
if q is not None:
|
||||||
|
q.put(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send(self, obj):
|
||||||
|
with self.send_lock:
|
||||||
|
self.proc.stdin.write((json.dumps(obj) + "\n").encode())
|
||||||
|
self.proc.stdin.flush()
|
||||||
|
|
||||||
|
def request(self, method, params=None, timeout=120):
|
||||||
|
rid = self.next_id
|
||||||
|
self.next_id += 1
|
||||||
|
q = queue.Queue()
|
||||||
|
with self.pending_lock:
|
||||||
|
self.pending[rid] = q
|
||||||
|
try:
|
||||||
|
self._send({"jsonrpc": "2.0", "id": rid, "method": method,
|
||||||
|
"params": params or {}})
|
||||||
|
return q.get(timeout=timeout)
|
||||||
|
finally:
|
||||||
|
with self.pending_lock:
|
||||||
|
self.pending.pop(rid, None)
|
||||||
|
|
||||||
|
def notify(self, method, params=None):
|
||||||
|
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
resp = self.request("initialize", {
|
||||||
|
"clientInfo": {"name": "codex-host", "version": "0.1"},
|
||||||
|
}, timeout=60)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
raise RuntimeError(f"initialize 失败: {resp}")
|
||||||
|
self.notify("initialized", {})
|
||||||
|
return resp["result"]
|
||||||
|
|
||||||
|
def create_thread(self, cwd, model=None, sandbox="workspace-write",
|
||||||
|
approval_policy="never", instructions=None):
|
||||||
|
params = {"cwd": os.path.abspath(cwd),
|
||||||
|
"approvalPolicy": approval_policy, "sandbox": sandbox}
|
||||||
|
if model:
|
||||||
|
params["model"] = model
|
||||||
|
if instructions:
|
||||||
|
params["developerInstructions"] = instructions
|
||||||
|
resp = self.request("thread/start", params, timeout=120)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
raise RuntimeError(f"thread/start 失败: {resp}")
|
||||||
|
return resp["result"]["thread"]["id"]
|
||||||
|
|
||||||
|
def list_threads(self):
|
||||||
|
resp = self.request("thread/list", {}, timeout=60)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
raise RuntimeError(f"thread/list 失败: {resp}")
|
||||||
|
return resp["result"]
|
||||||
|
|
||||||
|
def resume_thread(self, thread_id):
|
||||||
|
resp = self.request("thread/resume", {"threadId": thread_id}, timeout=120)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
raise RuntimeError(f"thread/resume 失败: {resp}")
|
||||||
|
return resp["result"]
|
||||||
|
|
||||||
|
def fork_thread(self, thread_id, cwd=None, sandbox=None):
|
||||||
|
params = {"threadId": thread_id}
|
||||||
|
if cwd:
|
||||||
|
params["cwd"] = os.path.abspath(cwd)
|
||||||
|
if sandbox:
|
||||||
|
params["sandbox"] = sandbox
|
||||||
|
resp = self.request("thread/fork", params, timeout=120)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
raise RuntimeError(f"thread/fork 失败: {resp}")
|
||||||
|
# fork 响应里 thread 字段形态随版本略有差异,尽量都取
|
||||||
|
r = resp["result"]
|
||||||
|
if isinstance(r, dict):
|
||||||
|
t = r.get("thread") or {}
|
||||||
|
return t.get("id") or r.get("threadId") or r.get("id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def archive_thread(self, thread_id):
|
||||||
|
resp = self.request("thread/archive", {"threadId": thread_id}, timeout=60)
|
||||||
|
if resp and "error" in resp:
|
||||||
|
raise RuntimeError(f"thread/archive 失败: {resp}")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def run_turn(self, thread_id, prompt, model=None, effort=None,
|
||||||
|
parent_turn_id=None, root_turn_id=None, timeout=600,
|
||||||
|
sandbox=None):
|
||||||
|
q = queue.Queue()
|
||||||
|
self.turn_queues[thread_id] = q
|
||||||
|
start_events = len(self.events)
|
||||||
|
try:
|
||||||
|
params = {"threadId": thread_id,
|
||||||
|
"input": [{"type": "text", "text": prompt}]}
|
||||||
|
if model:
|
||||||
|
params["model"] = model
|
||||||
|
if effort:
|
||||||
|
params["effort"] = effort
|
||||||
|
if sandbox:
|
||||||
|
# turn 级 sandboxPolicy 会覆盖 thread 级设置,且对后续
|
||||||
|
# turn 也生效。这是修复 fork 换 cwd 后被重置为 read-only
|
||||||
|
# 的关键:fork 时传 sandbox 参数服务端不认,turn 里传才认。
|
||||||
|
tmap = {"workspace-write": "workspaceWrite",
|
||||||
|
"read-only": "readOnly",
|
||||||
|
"danger-full-access": "dangerFullAccess"}
|
||||||
|
params["sandboxPolicy"] = {"type": tmap.get(sandbox, sandbox)}
|
||||||
|
if parent_turn_id:
|
||||||
|
params["parentTurnId"] = parent_turn_id
|
||||||
|
if root_turn_id:
|
||||||
|
params["rootTurnId"] = root_turn_id
|
||||||
|
resp = self.request("turn/start", params, timeout=120)
|
||||||
|
if not resp or "error" in resp:
|
||||||
|
return {"status": "failed", "text": "", "error": resp}
|
||||||
|
turn_id = None
|
||||||
|
if isinstance(resp.get("result"), dict):
|
||||||
|
t = resp["result"].get("turn") or {}
|
||||||
|
turn_id = t.get("id")
|
||||||
|
try:
|
||||||
|
done = q.get(timeout=timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
return {"status": "timeout", "text": "", "turn_id": turn_id}
|
||||||
|
turn = (done.get("params") or {}).get("turn") or {}
|
||||||
|
text = ""
|
||||||
|
for item in turn.get("items") or []:
|
||||||
|
if item.get("type") == "agentMessage" and \
|
||||||
|
item.get("phase") == "final_answer":
|
||||||
|
text = item.get("text") or text
|
||||||
|
if not text:
|
||||||
|
# 兜底:从本回合事件里找 final_answer
|
||||||
|
with self.events_lock:
|
||||||
|
evs = list(self.events[start_events:])
|
||||||
|
for ev in evs:
|
||||||
|
p = ev.get("params") or {}
|
||||||
|
item = p.get("item") or {}
|
||||||
|
if item.get("type") == "agentMessage" and \
|
||||||
|
item.get("phase") == "final_answer" and \
|
||||||
|
item.get("text"):
|
||||||
|
text = item["text"]
|
||||||
|
return {"status": turn.get("status", "completed"), "text": text,
|
||||||
|
"turn_id": turn.get("id") or turn_id,
|
||||||
|
"root_turn_id": turn.get("rootTurnId"),
|
||||||
|
"error": turn.get("error")}
|
||||||
|
finally:
|
||||||
|
self.turn_queues.pop(thread_id, None)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.proc.stdin.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
self.proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 宿主状态 / worktree / diff ----------------
|
||||||
|
# 生产加固:state 锁。锁只包住“读/写”这两个短临界区,不包住
|
||||||
|
# 整个模型 turn(turn 可能跑几分钟,包住它会把别的 host 进程
|
||||||
|
# 活活饿死)。保存时先在锁里重读磁盘,再把内存里的任务按名
|
||||||
|
# 覆盖、自动化按名合并,这样两个进程各改各的任务时,后保存
|
||||||
|
# 的不会把先保存的覆盖掉。tmp 文件带 pid,避免两个进程用
|
||||||
|
# 同一个 .tmp 互相踩。锁文件在 state 文件旁边,名如
|
||||||
|
# host-state.json.lock,用 fcntl.flock(Linux/macOS 都有)。
|
||||||
|
import contextlib
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
except ImportError: # Windows 等无 fcntl 的平台:退化为无锁
|
||||||
|
fcntl = None
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _state_lock(path, exclusive=True):
|
||||||
|
lock_path = path + ".lock"
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(lock_path)), exist_ok=True)
|
||||||
|
f = open(lock_path, "a+")
|
||||||
|
try:
|
||||||
|
if fcntl is not None:
|
||||||
|
fcntl.flock(f.fileno(),
|
||||||
|
fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if fcntl is not None:
|
||||||
|
try:
|
||||||
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_state_file(path):
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
return json.load(open(path))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"tasks": {}, "automations": []}
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path):
|
||||||
|
with _state_lock(path, exclusive=False):
|
||||||
|
return _read_state_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path, state):
|
||||||
|
with _state_lock(path, exclusive=True):
|
||||||
|
# 锁内重读 + 合并:内存任务覆盖同名磁盘任务,磁盘上
|
||||||
|
# 别的进程新增的任务保留;自动化按 name 合并,内存优先。
|
||||||
|
disk = _read_state_file(path)
|
||||||
|
merged_tasks = dict(disk.get("tasks") or {})
|
||||||
|
merged_tasks.update(state.get("tasks") or {})
|
||||||
|
disk_autos = {a.get("name"): a for a in (disk.get("automations") or [])
|
||||||
|
if isinstance(a, dict)}
|
||||||
|
for a in (state.get("automations") or []):
|
||||||
|
if isinstance(a, dict) and a.get("name"):
|
||||||
|
disk_autos[a["name"]] = a
|
||||||
|
merged = {"tasks": merged_tasks,
|
||||||
|
"automations": list(disk_autos.values())}
|
||||||
|
# 保留内存 state 里 tasks/automations 之外的顶层字段
|
||||||
|
for k, v in state.items():
|
||||||
|
if k not in ("tasks", "automations"):
|
||||||
|
merged[k] = v
|
||||||
|
tmp = f"{path}.tmp.{os.getpid()}"
|
||||||
|
json.dump(merged, open(tmp, "w"), indent=2, ensure_ascii=False)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
# 让调用方内存里的 state 也跟上合并结果,避免下次保存
|
||||||
|
# 又把别的进程的任务挤掉(调用方继续用这份 state 时)。
|
||||||
|
state.clear()
|
||||||
|
state.update(merged)
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(state_path, message):
|
||||||
|
"""run-due / merge / discard 等运维事件写日志,不依赖标准输出。
|
||||||
|
日志在 state 文件同目录的 host-run.log,定时任务跑完后
|
||||||
|
看这个文件就知道派发了什么、跳过了什么。"""
|
||||||
|
try:
|
||||||
|
log_path = os.path.join(os.path.dirname(os.path.abspath(state_path)),
|
||||||
|
"host-run.log")
|
||||||
|
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||||
|
with open(log_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"{ts} {message}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def git(args, cwd):
|
||||||
|
return subprocess.run(["git"] + args, cwd=cwd, capture_output=True,
|
||||||
|
text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def make_worktree(repo, task_name):
|
||||||
|
"""一任务一 worktree;不是 git 仓库时退化为独立目录拷贝。"""
|
||||||
|
repo = os.path.abspath(repo)
|
||||||
|
base = os.path.join(HERE, "work", "host-worktrees")
|
||||||
|
os.makedirs(base, exist_ok=True)
|
||||||
|
path = os.path.join(base, task_name)
|
||||||
|
if os.path.exists(path):
|
||||||
|
return path, None
|
||||||
|
if os.path.isdir(os.path.join(repo, ".git")):
|
||||||
|
branch = f"host/{task_name}"
|
||||||
|
r = git(["worktree", "add", path, "-b", branch, "HEAD"], repo)
|
||||||
|
if r.returncode != 0:
|
||||||
|
# 分支已存在时直接用它
|
||||||
|
r = git(["worktree", "add", path, branch], repo)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError(f"git worktree 失败: {r.stderr}")
|
||||||
|
return path, branch
|
||||||
|
# 非 git:复制一份
|
||||||
|
import shutil
|
||||||
|
shutil.copytree(repo, path)
|
||||||
|
return path, None
|
||||||
|
|
||||||
|
|
||||||
|
def task_diff(worktree):
|
||||||
|
if os.path.isdir(os.path.join(worktree, ".git")) or \
|
||||||
|
subprocess.run(["git", "-C", worktree, "rev-parse"],
|
||||||
|
capture_output=True).returncode == 0:
|
||||||
|
st = git(["status", "--porcelain"], worktree).stdout
|
||||||
|
diff = git(["diff", "HEAD"], worktree).stdout
|
||||||
|
untracked = git(["ls-files", "--others", "--exclude-standard"],
|
||||||
|
worktree).stdout
|
||||||
|
# 生产评审要点:新文件默认是 untracked,git diff 里看不到内容,
|
||||||
|
# 只列文件名等于没评审。把未追踪文本文件的内容也带上(截断)。
|
||||||
|
contents = {}
|
||||||
|
for rel in untracked.split():
|
||||||
|
p = os.path.join(worktree, rel)
|
||||||
|
if rel.endswith(".pyc") or "__pycache__" in rel:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(p, encoding="utf-8") as f:
|
||||||
|
contents[rel] = f.read(8000)
|
||||||
|
except Exception:
|
||||||
|
contents[rel] = "<binary or unreadable>"
|
||||||
|
return {"status": st, "diff": diff, "untracked": untracked,
|
||||||
|
"untracked_contents": contents}
|
||||||
|
return {"status": "", "diff": "",
|
||||||
|
"untracked": "\n".join(sorted(os.listdir(worktree)))}
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
|
||||||
|
approval_policy, use_worktree=True, parent_task=None):
|
||||||
|
if name in state["tasks"]:
|
||||||
|
raise RuntimeError(f"task 已存在: {name},先换名或 discard 它")
|
||||||
|
if repo and not os.path.isabs(repo) and not os.path.exists(repo):
|
||||||
|
cand = os.path.join(HERE, repo)
|
||||||
|
if os.path.exists(cand):
|
||||||
|
repo = cand
|
||||||
|
if not sandbox:
|
||||||
|
sandbox = "workspace-write"
|
||||||
|
cwd = repo
|
||||||
|
branch = None
|
||||||
|
if use_worktree and repo:
|
||||||
|
cwd, branch = make_worktree(repo, name)
|
||||||
|
thread_id = server.create_thread(cwd, model=model, sandbox=sandbox,
|
||||||
|
approval_policy=approval_policy)
|
||||||
|
task = {"name": name, "thread_id": thread_id, "cwd": cwd,
|
||||||
|
"repo": os.path.abspath(repo) if repo else None,
|
||||||
|
"branch": branch, "sandbox": sandbox, "status": "running",
|
||||||
|
"prompt": prompt, "parent_task": parent_task,
|
||||||
|
"created_at": time.time(), "turns": []}
|
||||||
|
state["tasks"][name] = task
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def run_task_turn(server, state, task, prompt, model, effort,
|
||||||
|
sandbox=None):
|
||||||
|
# task 自带 sandbox 时优先用它(只读评审任务不能被全局默认覆盖),
|
||||||
|
# 否则用调用方显式传的 sandbox;两者都没有时不传 sandboxPolicy,
|
||||||
|
# 保留 thread 原有设置(旧任务没有 sandbox 记录时尤其重要)。
|
||||||
|
use_sandbox = task.get("sandbox") or sandbox
|
||||||
|
result = server.run_turn(task["thread_id"], prompt, model=model,
|
||||||
|
effort=effort, sandbox=use_sandbox)
|
||||||
|
task["turns"].append({"prompt": prompt, "result": result})
|
||||||
|
task["status"] = "completed" if result["status"] == "completed" \
|
||||||
|
else "failed"
|
||||||
|
task["last_text"] = result.get("text", "")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config_file(path):
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
try:
|
||||||
|
return json.load(open(path))
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"配置文件读失败 {path}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(base_dir, p):
|
||||||
|
if not p:
|
||||||
|
return p
|
||||||
|
if os.path.isabs(p):
|
||||||
|
return p
|
||||||
|
return os.path.join(base_dir, p)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--codex-home", default=None)
|
||||||
|
ap.add_argument("--codex-bin", default=None)
|
||||||
|
ap.add_argument("--config", default=None,
|
||||||
|
help="项目配置文件,默认 host.py 同目录的 host-config.json(若存在)")
|
||||||
|
ap.add_argument("--state", default=None)
|
||||||
|
ap.add_argument("--model", default=None)
|
||||||
|
ap.add_argument("--effort", default=None)
|
||||||
|
ap.add_argument("--sandbox", default=None)
|
||||||
|
ap.add_argument("--approval-policy", default=None)
|
||||||
|
ap.add_argument("--approval", default=None, choices=["auto", "deny"])
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p = sub.add_parser("init")
|
||||||
|
p.add_argument("--repo", default=None)
|
||||||
|
p.add_argument("--force", action="store_true")
|
||||||
|
sub.add_parser("config")
|
||||||
|
p = sub.add_parser("dispatch")
|
||||||
|
p.add_argument("--name", required=True)
|
||||||
|
p.add_argument("--task", required=True)
|
||||||
|
p.add_argument("--repo", default=None)
|
||||||
|
p.add_argument("--no-worktree", action="store_true")
|
||||||
|
|
||||||
|
sub.add_parser("list")
|
||||||
|
p = sub.add_parser("status"); p.add_argument("--task", required=True)
|
||||||
|
p = sub.add_parser("review"); p.add_argument("--task", required=True)
|
||||||
|
p = sub.add_parser("merge"); p.add_argument("--task", required=True)
|
||||||
|
p = sub.add_parser("discard"); p.add_argument("--task", required=True)
|
||||||
|
p = sub.add_parser("archive"); p.add_argument("--task", required=True)
|
||||||
|
p = sub.add_parser("fork"); p.add_argument("--task", required=True); p.add_argument("--name", required=True); p.add_argument("--sandbox", default=None)
|
||||||
|
p = sub.add_parser("compact"); p.add_argument("--task", required=True)
|
||||||
|
p = sub.add_parser("run"); p.add_argument("--task", required=True); p.add_argument("--prompt", required=True)
|
||||||
|
p = sub.add_parser("delegate")
|
||||||
|
p.add_argument("--parent-task", required=True); p.add_argument("--task", required=True)
|
||||||
|
p.add_argument("--name", required=True); p.add_argument("--report-to-parent", action="store_true")
|
||||||
|
p = sub.add_parser("goal")
|
||||||
|
p.add_argument("--name", required=True); p.add_argument("--task", required=True)
|
||||||
|
p.add_argument("--repo", default=None); p.add_argument("--verify", required=True)
|
||||||
|
p.add_argument("--max-rounds", type=int, default=3)
|
||||||
|
p = sub.add_parser("parallel"); p.add_argument("--jobs", required=True)
|
||||||
|
p = sub.add_parser("automations"); p.add_argument("--add", action="store_true")
|
||||||
|
p.add_argument("--name"); p.add_argument("--task"); p.add_argument("--repo")
|
||||||
|
p.add_argument("--interval", type=int, default=86400)
|
||||||
|
p.add_argument("--cron-line", action="store_true",
|
||||||
|
help="只打印 run-due 的 crontab 行,不装机")
|
||||||
|
p.add_argument("--install-cron", action="store_true",
|
||||||
|
help="把 run-due 写进当前用户 crontab(幂等,已有则不重复)")
|
||||||
|
p.add_argument("--cron-every-minutes", type=int, default=30)
|
||||||
|
sub.add_parser("run-due")
|
||||||
|
|
||||||
|
args = ap.parse_args()
|
||||||
|
# 配置解析:CLI > 配置文件 > 内置默认。配置文件里绝不放密钥,
|
||||||
|
# API key 只走环境变量(中转站用 XCM_API_KEY 那种),这是硬规则。
|
||||||
|
config_path = args.config or os.path.join(HERE, "host-config.json")
|
||||||
|
cfg = _load_config_file(config_path) if os.path.exists(config_path) else {}
|
||||||
|
cfg_dir = os.path.dirname(os.path.abspath(config_path))
|
||||||
|
if args.cmd == "init":
|
||||||
|
if os.path.exists(config_path) and not args.force:
|
||||||
|
print(f"配置文件已存在:{config_path},加 --force 才会覆盖")
|
||||||
|
return
|
||||||
|
init_cfg = {
|
||||||
|
"repo": args.repo or cfg.get("repo") or "",
|
||||||
|
"model": "gpt-6.1-sol", "effort": "low",
|
||||||
|
"sandbox": "workspace-write", "approval_policy": "never",
|
||||||
|
"approval": "deny", "codex_home": "",
|
||||||
|
"state": "host-state.json",
|
||||||
|
"_note": "API key 不放这里,只走环境变量",
|
||||||
|
}
|
||||||
|
json.dump(init_cfg, open(config_path, "w"), indent=2,
|
||||||
|
ensure_ascii=False)
|
||||||
|
print(f"配置文件已写:{config_path},请按需改 repo/model 后再用")
|
||||||
|
return
|
||||||
|
# 有效配置(init/config 之外的命令都用它)
|
||||||
|
args.model = args.model or cfg.get("model") or "gpt-6.1-sol"
|
||||||
|
args.effort = args.effort or cfg.get("effort") or "low"
|
||||||
|
args.sandbox = args.sandbox or cfg.get("sandbox")
|
||||||
|
args.approval_policy = args.approval_policy or cfg.get("approval_policy") or "never"
|
||||||
|
args.approval = args.approval or cfg.get("approval") or "deny"
|
||||||
|
args.codex_home = args.codex_home or _resolve_path(cfg_dir, cfg.get("codex_home"))
|
||||||
|
args.codex_bin = args.codex_bin or _resolve_path(cfg_dir, cfg.get("codex_bin"))
|
||||||
|
args.state = args.state or _resolve_path(cfg_dir, cfg.get("state")) or DEFAULT_STATE
|
||||||
|
if args.cmd == "config":
|
||||||
|
print(json.dumps({
|
||||||
|
"config_file": config_path if os.path.exists(config_path) else None,
|
||||||
|
"model": args.model, "effort": args.effort,
|
||||||
|
"sandbox": args.sandbox, "approval_policy": args.approval_policy,
|
||||||
|
"approval": args.approval, "codex_home": args.codex_home,
|
||||||
|
"codex_bin": args.codex_bin, "state": args.state,
|
||||||
|
"repo": cfg.get("repo"),
|
||||||
|
}, indent=2, ensure_ascii=False))
|
||||||
|
return
|
||||||
|
# dispatch/goal/automations 的 repo 缺省用配置里的 repo
|
||||||
|
if args.cmd in ("dispatch", "goal"):
|
||||||
|
if not args.repo:
|
||||||
|
args.repo = cfg.get("repo")
|
||||||
|
if not args.repo:
|
||||||
|
raise RuntimeError("缺少 --repo,也没有在 host-config.json 里配 repo")
|
||||||
|
if args.cmd == "automations" and (args.cron_line or args.install_cron):
|
||||||
|
# cron 行生成:只打印 / 装机,不跑模型,不花钱。
|
||||||
|
bin_path = args.codex_bin or default_codex_bin()
|
||||||
|
log_path = os.path.join(os.path.dirname(os.path.abspath(args.state)),
|
||||||
|
"host-run.log")
|
||||||
|
line = (f"*/{args.cron_every_minutes} * * * * cd {HERE} && "
|
||||||
|
f"CODEX_BIN={bin_path} python3 host.py --config {config_path} "
|
||||||
|
f"run-due >> {log_path} 2>&1 # codex-host-run-due")
|
||||||
|
if args.cron_line:
|
||||||
|
print(line)
|
||||||
|
print("注意:这行本身不生效,要装进 crontab 才生效;"
|
||||||
|
"VM 会被平台回收,crontab 不一定能活过回收,"
|
||||||
|
"常驻机器用 crontab,VM 上更稳的是 Muse cron 定时调 run-due。")
|
||||||
|
return
|
||||||
|
if args.install_cron:
|
||||||
|
cur = subprocess.run(["crontab", "-l"], capture_output=True,
|
||||||
|
text=True).stdout or ""
|
||||||
|
if "codex-host-run-due" in cur:
|
||||||
|
print("crontab 里已有 codex-host-run-due,未重复安装")
|
||||||
|
return
|
||||||
|
new = (cur.rstrip("\n") + "\n" + line + "\n") if cur.strip() else line + "\n"
|
||||||
|
subprocess.run(["crontab", "-"], input=new, text=True, check=True)
|
||||||
|
print(f"已安装 crontab:{line}")
|
||||||
|
return
|
||||||
|
state = load_state(args.state)
|
||||||
|
srv = Server(codex_bin=args.codex_bin, codex_home=args.codex_home,
|
||||||
|
approval=args.approval)
|
||||||
|
try:
|
||||||
|
srv.initialize()
|
||||||
|
if args.cmd == "dispatch":
|
||||||
|
task = dispatch(srv, state, args.name, args.task, args.repo,
|
||||||
|
args.model, args.effort, args.sandbox,
|
||||||
|
args.approval_policy,
|
||||||
|
use_worktree=not args.no_worktree)
|
||||||
|
# 生产教训:先落盘任务登记再跑 turn,中途被杀/超时后
|
||||||
|
# 任务还在 host-state 里,可用 run 续跑;否则 turn 中断
|
||||||
|
# 就等于任务丢了(2026-10-25 委派评审 300 秒超时实测)。
|
||||||
|
save_state(args.state, state)
|
||||||
|
result = run_task_turn(srv, state, task, args.task,
|
||||||
|
args.model, args.effort, args.sandbox)
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(json.dumps({"task": args.name, "thread_id": task["thread_id"],
|
||||||
|
"cwd": task["cwd"], "status": task["status"],
|
||||||
|
"text": result.get("text", "")},
|
||||||
|
indent=2, ensure_ascii=False))
|
||||||
|
elif args.cmd == "list":
|
||||||
|
for name, t in state["tasks"].items():
|
||||||
|
print(f"{name}: thread={t['thread_id'][:8]} status={t['status']} "
|
||||||
|
f"cwd={t['cwd']} branch={t.get('branch')}")
|
||||||
|
print(f"app-server threads: "
|
||||||
|
f"{json.dumps(srv.list_threads(), ensure_ascii=False)[:500]}")
|
||||||
|
elif args.cmd == "status":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
print(json.dumps(t, indent=2, ensure_ascii=False))
|
||||||
|
elif args.cmd == "review":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
d = task_diff(t["cwd"])
|
||||||
|
t["diff"] = d
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"status:\n{d['status']}\nuntracked:\n{d['untracked']}\n"
|
||||||
|
f"diff:\n{d['diff'][:8000]}")
|
||||||
|
for rel, content in (d.get("untracked_contents") or {}).items():
|
||||||
|
print(f"\n--- untracked file: {rel} ---\n{content}")
|
||||||
|
elif args.cmd == "merge":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
if not t.get("repo") or not t.get("branch"):
|
||||||
|
print("非 git worktree 任务,无法 merge,直接看 diff 后手动处理")
|
||||||
|
return
|
||||||
|
# 关键一步:worktree 里的改动默认是未提交的,直接 merge 分支
|
||||||
|
# 合不到任何东西。先在 worktree 里提交,再把分支合并进主仓。
|
||||||
|
git(["add", "-A"], t["cwd"])
|
||||||
|
c = git(["commit", "-m", f"host: {args.task}"], t["cwd"])
|
||||||
|
if c.returncode != 0 and "nothing to commit" not in c.stdout + c.stderr:
|
||||||
|
print(f"worktree 提交失败:{c.stderr}")
|
||||||
|
return
|
||||||
|
r = git(["merge", "--no-ff", t["branch"], "-m", f"host: {args.task}"],
|
||||||
|
t["repo"])
|
||||||
|
if r.returncode != 0:
|
||||||
|
git(["merge", "--abort"], t["repo"])
|
||||||
|
print(f"合并冲突,已 abort:{r.stderr}")
|
||||||
|
return
|
||||||
|
git(["worktree", "remove", "--force", t["cwd"]], t["repo"])
|
||||||
|
git(["branch", "-d", t["branch"]], t["repo"])
|
||||||
|
t["status"] = "merged"
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"merged {t['branch']} into {t['repo']}")
|
||||||
|
elif args.cmd == "discard":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
if t.get("repo") and t.get("branch"):
|
||||||
|
git(["worktree", "remove", "--force", t["cwd"]], t["repo"])
|
||||||
|
git(["branch", "-D", t["branch"]], t["repo"])
|
||||||
|
t["status"] = "discarded"
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"discarded {args.task}")
|
||||||
|
elif args.cmd == "compact":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
# 注意:compact 要求 thread 已在当前 server 里加载,
|
||||||
|
# 直接对未加载的旧 thread 调会报 thread not found,
|
||||||
|
# 所以先 resume 再 compact,这是协议层面的先后顺序。
|
||||||
|
srv.resume_thread(t["thread_id"])
|
||||||
|
resp = srv.request("thread/compact/start",
|
||||||
|
{"threadId": t["thread_id"]}, timeout=120)
|
||||||
|
print(f"compact {args.task}: {resp}")
|
||||||
|
elif args.cmd == "archive":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
srv.archive_thread(t["thread_id"])
|
||||||
|
t["status"] = "archived"
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"archived {args.task}")
|
||||||
|
elif args.cmd == "fork":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
# 生产教训:fork 只分叉 thread 历史,不自动隔离工作区。
|
||||||
|
# 旧实现复用原 worktree,合并后 worktree 已删时 fork 出来的
|
||||||
|
# thread 连 cwd 都没了,只能只读。正确做法:有 repo 时给
|
||||||
|
# fork 单独建一个新 worktree,并把 cwd 传给 thread/fork。
|
||||||
|
new_cwd = t["cwd"]
|
||||||
|
new_branch = t.get("branch")
|
||||||
|
if t.get("repo"):
|
||||||
|
old_cwd = t["cwd"]
|
||||||
|
new_cwd, new_branch = make_worktree(t["repo"], args.name)
|
||||||
|
if os.path.isdir(old_cwd):
|
||||||
|
# 未合并任务:新 worktree 只有主仓内容,没有原任务的
|
||||||
|
# 改动。把原 worktree 的文件(不含 .git)拷过去,
|
||||||
|
# fork 出来的 thread 历史和工作区才对得上。
|
||||||
|
import shutil
|
||||||
|
for root, dirs, files in os.walk(old_cwd):
|
||||||
|
dirs[:] = [d for d in dirs
|
||||||
|
if d not in (".git", "__pycache__")]
|
||||||
|
for fn in files:
|
||||||
|
if fn.endswith(".pyc") or fn == ".git":
|
||||||
|
# .git 在 worktree 里是一个指向 gitdir 的文件,
|
||||||
|
# 拷过去会把新 worktree 的指针指回旧 worktree,
|
||||||
|
# 导致 git worktree 校验失败(2026-10-25 实测)。
|
||||||
|
continue
|
||||||
|
src = os.path.join(root, fn)
|
||||||
|
rel = os.path.relpath(src, old_cwd)
|
||||||
|
dst = os.path.join(new_cwd, rel)
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
# fork 不传 sandbox 时服务端默认 read-only(实测),
|
||||||
|
# 所以必须显式传原任务的沙箱,旧任务没存沙箱时默认 workspace-write。
|
||||||
|
fork_sandbox = args.sandbox or t.get("sandbox") or "workspace-write"
|
||||||
|
new_id = srv.fork_thread(t["thread_id"], cwd=new_cwd,
|
||||||
|
sandbox=fork_sandbox)
|
||||||
|
nt = dict(t)
|
||||||
|
nt.update({"name": args.name, "thread_id": new_id,
|
||||||
|
"cwd": new_cwd, "branch": new_branch,
|
||||||
|
"status": "forked", "parent_task": args.task})
|
||||||
|
state["tasks"][args.name] = nt
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"forked {args.task} -> {args.name} thread={new_id} "
|
||||||
|
f"cwd={new_cwd}")
|
||||||
|
elif args.cmd == "run":
|
||||||
|
t = state["tasks"][args.task]
|
||||||
|
srv.resume_thread(t["thread_id"])
|
||||||
|
result = run_task_turn(srv, state, t, args.prompt,
|
||||||
|
args.model, args.effort, args.sandbox)
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
elif args.cmd == "delegate":
|
||||||
|
parent = state["tasks"][args.parent_task]
|
||||||
|
# 子 thread:cwd 与父任务相同 worktree 时只读更安全,
|
||||||
|
# 这里默认沿用父 cwd + read-only,避免子任务改坏父任务的改动
|
||||||
|
sub_thread = srv.create_thread(parent["cwd"], model=args.model,
|
||||||
|
sandbox="read-only",
|
||||||
|
approval_policy="never")
|
||||||
|
sub = {"name": args.name, "thread_id": sub_thread,
|
||||||
|
"cwd": parent["cwd"], "repo": parent.get("repo"),
|
||||||
|
"branch": None, "sandbox": "read-only", "status": "running",
|
||||||
|
"prompt": args.task, "parent_task": args.parent_task,
|
||||||
|
"created_at": time.time(), "turns": []}
|
||||||
|
state["tasks"][args.name] = sub
|
||||||
|
save_state(args.state, state)
|
||||||
|
result = run_task_turn(srv, state, sub, args.task,
|
||||||
|
args.model, args.effort, args.sandbox)
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(json.dumps({"sub_task": args.name, "status": sub["status"],
|
||||||
|
"text": result.get("text", "")},
|
||||||
|
indent=2, ensure_ascii=False))
|
||||||
|
if args.report_to_parent:
|
||||||
|
srv.resume_thread(parent["thread_id"])
|
||||||
|
rep = run_task_turn(srv, state, parent,
|
||||||
|
f"子任务 {args.name} 的结论如下,请汇总到主任务结论里,"
|
||||||
|
f"不要重复做子任务的工作:\n{result.get('text', '')}",
|
||||||
|
args.model, args.effort, args.sandbox)
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"reported to parent: {rep.get('text', '')[:1000]}")
|
||||||
|
elif args.cmd == "goal":
|
||||||
|
task = dispatch(srv, state, args.name, args.task, args.repo,
|
||||||
|
args.model, args.effort, args.sandbox,
|
||||||
|
args.approval_policy)
|
||||||
|
save_state(args.state, state)
|
||||||
|
ok = False
|
||||||
|
last_verify = ""
|
||||||
|
for round_no in range(1, args.max_rounds + 1):
|
||||||
|
if round_no == 1:
|
||||||
|
prompt = args.task
|
||||||
|
else:
|
||||||
|
# 生产教训:只说“验收未通过” agent 不知道改什么,
|
||||||
|
# 2026-10-25 实测 prod-retry 3 轮都原地不动。
|
||||||
|
# 必须把验收命令和失败输出喂回去,才是可用的目标循环。
|
||||||
|
prompt = (f"上一轮验收未通过,请针对性修复,不要重复已完成的部分。\n"
|
||||||
|
f"目标:{args.task}\n"
|
||||||
|
f"验收命令:{args.verify}\n"
|
||||||
|
f"上一轮验收输出:\n{last_verify}")
|
||||||
|
result = run_task_turn(srv, state, task, prompt,
|
||||||
|
args.model, args.effort, args.sandbox)
|
||||||
|
v = subprocess.run(args.verify, shell=True, cwd=task["cwd"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
last_verify = (v.stdout + v.stderr).strip()[:1000] or \
|
||||||
|
f"exit code {v.returncode}"
|
||||||
|
print(f"round {round_no}: turn={result['status']} "
|
||||||
|
f"verify_rc={v.returncode} verify_out={last_verify[:300]}")
|
||||||
|
if v.returncode == 0:
|
||||||
|
ok = True
|
||||||
|
break
|
||||||
|
task["status"] = "goal-met" if ok else "goal-unmet"
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"goal {'met' if ok else 'unmet'} after rounds")
|
||||||
|
elif args.cmd == "parallel":
|
||||||
|
jobs = json.load(open(args.jobs))
|
||||||
|
# 先全部派发(建 thread + worktree),再并发跑 turn
|
||||||
|
tasks = []
|
||||||
|
for job in jobs:
|
||||||
|
t = dispatch(srv, state, job["name"], job["task"],
|
||||||
|
job.get("repo", ""), args.model, args.effort,
|
||||||
|
job.get("sandbox", args.sandbox),
|
||||||
|
args.approval_policy,
|
||||||
|
use_worktree=bool(job.get("repo")))
|
||||||
|
tasks.append((job, t))
|
||||||
|
save_state(args.state, state)
|
||||||
|
|
||||||
|
def _run(job_task):
|
||||||
|
job, t = job_task
|
||||||
|
return job["name"], run_task_turn(srv, state, t, job["task"],
|
||||||
|
args.model, args.effort, args.sandbox)
|
||||||
|
with ThreadPoolExecutor(max_workers=len(tasks)) as ex:
|
||||||
|
for name, result in ex.map(_run, tasks):
|
||||||
|
print(f"{name}: {result['status']} "
|
||||||
|
f"{result.get('text', '')[:300]}")
|
||||||
|
save_state(args.state, state)
|
||||||
|
elif args.cmd == "automations":
|
||||||
|
if args.add:
|
||||||
|
state["automations"].append({
|
||||||
|
"name": args.name, "task": args.task,
|
||||||
|
"repo": args.repo or cfg.get("repo"),
|
||||||
|
"interval": args.interval, "last_run": 0})
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f"automation added: {args.name} every {args.interval}s")
|
||||||
|
else:
|
||||||
|
print(json.dumps(state["automations"], indent=2,
|
||||||
|
ensure_ascii=False))
|
||||||
|
elif args.cmd == "run-due":
|
||||||
|
now = time.time()
|
||||||
|
for auto in state["automations"]:
|
||||||
|
if now - auto.get("last_run", 0) < auto["interval"]:
|
||||||
|
log_event(args.state,
|
||||||
|
f"run-due skip {auto['name']} "
|
||||||
|
f"(interval {auto['interval']}s, not due)")
|
||||||
|
continue
|
||||||
|
name = f"{auto['name']}-{int(now)}"
|
||||||
|
task = dispatch(srv, state, name, auto["task"], auto["repo"],
|
||||||
|
args.model, args.effort, args.sandbox,
|
||||||
|
args.approval_policy)
|
||||||
|
save_state(args.state, state)
|
||||||
|
result = run_task_turn(srv, state, task, auto["task"],
|
||||||
|
args.model, args.effort, args.sandbox)
|
||||||
|
auto["last_run"] = now
|
||||||
|
log_event(args.state,
|
||||||
|
f"run-due {auto['name']} -> {name}: "
|
||||||
|
f"{result['status']}")
|
||||||
|
print(f"automation {auto['name']} -> {name}: "
|
||||||
|
f"{result['status']} (进评审队列,见 host.py status/review)")
|
||||||
|
save_state(args.state, state)
|
||||||
|
finally:
|
||||||
|
srv.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
宿主层多 agent 编排示例:这层开源 core 里没有,得自己写。
|
||||||
|
|
||||||
|
做法不是让一个 thread 内部无限派生,而是宿主并行开多个 thread,
|
||||||
|
每个 thread 一个独立工作目录(最好是 git worktree,互不污染),
|
||||||
|
最后由宿主收集各 thread 的 turn 结果再汇总。桌面版“多线程指挥台”
|
||||||
|
的感觉,核心就是这一层,而不是 app-server 本身。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 run_parallel.py --jobs jobs.json
|
||||||
|
jobs.json 格式:
|
||||||
|
[{"cwd": "/tmp/job-a", "prompt": "检查 a 模块的安全问题,只读,不要改代码"},
|
||||||
|
{"cwd": "/tmp/job-b", "prompt": "检查 b 模块的测试覆盖,只读,不要改代码"}]
|
||||||
|
每个 job 默认 sandbox=read-only、approval-policy=never,只做并行审查;
|
||||||
|
要并行改代码时,把 cwd 换成各自的 worktree,并把下面的参数改成
|
||||||
|
workspace-write,且事后逐个 review diff 再合并,冲突仍需人工处理。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--jobs", required=True)
|
||||||
|
ap.add_argument("--codex-home", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
jobs = json.load(open(args.jobs))
|
||||||
|
procs = []
|
||||||
|
for i, job in enumerate(jobs):
|
||||||
|
cwd = job["cwd"]
|
||||||
|
if not os.path.isabs(cwd):
|
||||||
|
cwd = os.path.join(HERE, cwd)
|
||||||
|
cmd = [sys.executable, os.path.join(HERE, "client.py"),
|
||||||
|
"--prompt", job["prompt"],
|
||||||
|
"--cwd", cwd,
|
||||||
|
"--sandbox", job.get("sandbox", "read-only"),
|
||||||
|
"--approval-policy", "never"]
|
||||||
|
if args.codex_home:
|
||||||
|
cmd += ["--codex-home", args.codex_home]
|
||||||
|
print(f"[job {i}] cwd={job['cwd']} prompt={job['prompt'][:60]}")
|
||||||
|
procs.append((i, subprocess.Popen(cmd)))
|
||||||
|
failed = 0
|
||||||
|
for i, p in procs:
|
||||||
|
rc = p.wait()
|
||||||
|
print(f"[job {i}] exit={rc}")
|
||||||
|
failed += rc != 0
|
||||||
|
print(f"完成:{len(procs) - failed}/{len(procs)} 成功")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 启动官方 codex app-server(照搬开源核心,不改 agent 循环、不改沙箱)。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./start.sh # stdio,适合本机客户端(client.py)
|
||||||
|
# ./start.sh --listen unix:///tmp/codex.sock # Unix socket,多客户端共享一个 server
|
||||||
|
# ./start.sh --listen ws://127.0.0.1:8080 # WebSocket,远程/另一台机器接
|
||||||
|
# CODEX_HOME=~/.codex ./start.sh # 默认就是 ~/.codex,不设也一样
|
||||||
|
#
|
||||||
|
# 先只校验配置、不启动(排障用):
|
||||||
|
# codex app-server --print-startup-config
|
||||||
|
#
|
||||||
|
# 注意:
|
||||||
|
# 1. 模型和登录不在开源里:首次使用前先在你自己的机器上完成
|
||||||
|
# `codex login`(ChatGPT 登录)或配好 API key 的 model provider,
|
||||||
|
# 否则 thread/start 能建、turn/start 会在模型调用处失败。
|
||||||
|
# 2. 不要把 --dangerously-bypass-approvals-and-sandbox 写进常驻启动,
|
||||||
|
# 那是给外部已沙箱化的环境准备的,见 README 安全一节。
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# 本机 VM 修复:系统里的 codex 是裸二进制,旁边没有
|
||||||
|
# codex-code-mode-host 和 codex-resources/bwrap,工具会 fail closed。
|
||||||
|
# 本项目 bin/ 里是配对好的官方 0.164.0-alpha.9 三件套
|
||||||
|
# (codex + codex-code-mode-host + codex-resources/bwrap),
|
||||||
|
# 优先用它;Mac 上用 npm 正常安装的 codex 自带这三件套,
|
||||||
|
# 不需要 bin/,直接用 PATH 里的 codex 即可。
|
||||||
|
if [ -n "${CODEX_BIN:-}" ]; then
|
||||||
|
CODEX_CMD="$CODEX_BIN"
|
||||||
|
elif [ -x "$(dirname "$0")/bin/codex" ]; then
|
||||||
|
CODEX_CMD="$(dirname "$0")/bin/codex"
|
||||||
|
elif command -v codex >/dev/null 2>&1; then
|
||||||
|
CODEX_CMD="codex"
|
||||||
|
else
|
||||||
|
echo "找不到 codex,请先装官方 codex" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "codex: $($CODEX_CMD --version 2>&1 | head -1) ($CODEX_CMD)"
|
||||||
|
echo "CODEX_HOME: ${CODEX_HOME:-$HOME/.codex}"
|
||||||
|
exec "$CODEX_CMD" app-server "$@"
|
||||||
Reference in new issue
Block a user