Files
codex-app-server/docs/marker-tools.md
T

355 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务内标记工具(marker tools)
适用基线:main d129f7b 上的 feat-marker-tools 分支(本地提交,未推、
未建 PR、未部署)。本批只做四类标记工具,不要把本文档读成
“所有 Codex app 线程工具都已实现”:handoff、worktree 创建状态、
PR 检查诊断、automation、clock-sleep 都不在本批;MCP 真工具路
(同轮函数返回)也不在本批。
## 一句话原理
agent 不用联网、不用凭证。agent 在回合输出里写标记块,本地与
Docker 执行器都经 sandbox 的结果解析提取标记块(与 task-message
同款两处解析),可信的 worker 在 completed 轮次后执行。
异步语义(必须先讲清,否则会被当成函数调用):
- 写块 → 回合结束 → worker 执行 → 报告拼成一条后续消息、
下一轮才回到父任务。不是同轮函数返回。
- 轮询与等待在 worker 执行期间循环查库、占 worker 槽位(与
协调员等齐同款代价);超时或父任务被取消时中止。
- 只有 completed 轮次的块被执行;失败 / 超时 / 取消 / 配额中断
的轮次不执行;父任务执行期间被取消时也不执行。
- 拒绝只记报告与审计、不炸任务、不改目标状态。
## 边界:谁能用、谁不能用
- 能用:普通任务。普通任务当项目经理、自己派生员工、轮询员工、
等一批员工、取消或重试员工,走的就是这批工具。
- 不能用:goal 任务与协调员任务不解析这些标记块(只收不发)。
协调员任务本身不跑模型回合,等齐循环也不会在中途排空后续
消息做判断,所以协调员 PM 不能因此获得任务内工具。
- 容易混淆的一点:协调员的子任务本身是普通任务(不是协调员
任务),它写标记块会被解析;它的派生代数从 0 计(见“派生护栏”)。
## 四个工具的标记格式
### task-create:新建平级主线任务
```text
```task-create
{"name": "test-xxx", "prompt": "...", "model": null,
"effort": null, "executor": "docker"}
```
```
- 新任务是平级主线,不是子代理、不是 fork;不继承父任务历史;
环境基线继承父任务的 environment_id,但 worker 给它独立的
tenant/worktree,不动父任务的 cwd;父任务未提交的改动不会
自动带过去。
- prompt 必填、1-20000 字;name 可选、1-100 字(仅用于派生报告,
tasks 表没有 name 列,不落库、不改 prompt);model 不做白名单;
effort 只能是 low / medium / high / xhigh / max;executor 只能是
local / docker / cloud,未指定时继承父任务。
- 只建普通任务:不通过标记建协调员任务与 goal 任务。
- 每轮最多 5 块,多余的忽略;非法 JSON、prompt 缺失/空串/超长、
name 超长、effort/executor 非法时整块跳过;正文里的行首围栏
会把块截断成非法 JSON 而被跳过(与 task-message 同款非贪婪匹配)。
### task-poll / task-read:按任务 id 轮询阅读
```text
```task-poll
{"task_id": "<目标任务 id>", "interval_seconds": 5,
"timeout_seconds": 300, "chars": 2000}
```
```task-read
{"task_id": "<目标任务 id>", "wait_seconds": 30}
```
```
- task-read 是 task-poll 的别名;read-thread / read_thread 也是
同款别名。不传 wait_seconds 时立即读一次快照;传了则等这么久
再读一次(等价于 interval = timeout = wait_seconds)。
- interval 钳制 1-60 秒,timeout 钳制 0-3600 秒(0 = 立即快照),
chars 钳制 200-8000 字;各类块合计每轮最多 5 块。
- 轮询目标不存在、跨项目、轮询自己时,报告里写清拒绝原因,
不改任何状态。轮询目标是只读的,goal 任务也可以被轮询。
### wait-threads:一次等多个目标任务
```text
```wait-threads
{"task_ids": ["B", "C"], "interval_seconds": 5,
"timeout_seconds": 300, "chars": 2000}
```
```
- wait_threads 是同款写法;task_ids 也接受 thread_ids / threads
两个别名;去重保序。
- 每块最多 8 个目标,每轮最多 2 块。8 这个数是刻意与服务端
POST /tasks/{id}/wait 的“一次最多 8 个线程”对齐的;spawn
分支原先的每块 10 个在本批收紧到 8 个,不要再用 10。
- 并行查库:每轮查一次全部目标,不是逐个串行等超时相加;
全部终态或超时后把每目标快照一起排回父任务。
- 缺失、跨项目、等待自己的目标当场记为已处理(写清原因),
不让它们把整批拖到超时;其余目标继续等到终态或超时。
- 超时口径与服务端 /wait 不同,且是故意的:服务端 /wait 是给
外部调用方持 HTTP 连接等的,上限 120 秒;标记 wait 是 worker
在回合后直连库循环查,不持 HTTP 连接,超时钳制与 task-poll
同款(0-3600 秒),占的是 worker 槽位而不是 HTTP 连接。
两者不要混用、不要互抄上限。
### task-cancel / task-retry:取消 / 重试已存在的任务
```text
```task-cancel
{"task_id": "<目标任务 id>"}
```
```task-retry
{"task_id": "<目标任务 id>"}
```
```
- task_cancel / task_retry 是同款写法;task_id 也接受 to /
to_task_id / thread_id 别名;每种每轮最多 5 块。
- 取消复用 worker 现有的取消路径语义:取消后状态为 interrupted,
正在跑的进程组 / 容器按现有取消路径真杀,不只改库状态。
- 重试与服务端 POST /tasks/{id}/retry 对齐:仅 failed /
interrupted / merge-failed 可重试,重试时清空上一轮
result_text / error_text / actual_model、状态回 queued、
prompt 与模型等原样保留、记审计。重试本身不花模型钱,
重试后的新一轮照常计费。
- 执行顺序:创建 → 取消 → 重试 → 轮询 → 等待。创建在前让同一轮
刚创建的任务立刻能被轮询;控制操作在快照之前让轮询/等待读到
取消或重试后的新状态。
## 门禁(task-cancel / task-retry,缺一不可)
纯函数层(cloud/marker_tools.py check_control_gate)与 worker
权限层(worker.py _deliver_marker_tools)两道,拒绝时只记报告
与审计、不改目标状态:
1. 目标存在;同项目;不是自己。
2. 非 goal 任务(verify_command 非空或 goal_mode 为真都算 goal)。
3. 取消要求目标非终态;重试要求目标仅为 failed / interrupted /
merge-failed。已合并(merged)两者都拒绝;merge-failed 本身
是终态,所以取消拒绝、重试接受。
4. 关系二选一:目标在发送方派生链上(沿 parent_task_id 链最多
32 步能追溯到发送方),或目标 created_by 与发送方 created_by
相同。“同项目猜到 id 就能取消任何人的任务”在此被挡住。
5. 权限层:取消要求发送方创建人有项目 create_task 权限(与
服务端取消口径同款),重试要求有 approve_merge 权限(与
服务端重试口径同款)。权限检查本身抛异常时收紧到仅派生链内
可用,同创建人-only 的目标一律拒绝,并把原因写进报告。
## 派生护栏与费用
- 派生深度上限 3 代(父任务本身是派生任务时逐代计;协调员子任务
的代数从 0 计,见“边界”);单个父任务直接派生上限 20 个
(不含协调员子任务);超限的块跳过。
- 派生任务是独立主线:父任务完成或取消都不自动杀派生任务;
想停派生任务,用 task-cancel 或控制台 / API 显式取消它。
协调员孤儿收口只扫 coordinator_part 非空的协调员子任务,
不扫派生任务。
- 费用与配额:派生任务 created_by 跟父任务走,认领时照常过
key 门与配额门;创建时 worker 先查一次父任务配额,超限时
整轮创建跳过(排队本身不花钱,但不让超限方再批量排队)。
## 诚实边界(与服务端口径不一致处,逐条列出)
- 重试的 goal 拒绝比服务端严:服务端 POST /tasks/{id}/retry
没有单独的 goal 检查,标记重试为了不破坏 goal 循环语义,
明确拒绝 goal 目标。这是标记口径比服务端口径严,不是遗漏。
- 取消的 goal 拒绝同理:服务端取消也没有单独的 goal 检查,
标记取消同样拒绝 goal 目标。
- 服务端重试是双人门(触发人与原任务创建人任一超限即拦,
且触发人须有 approve_merge 权限);标记重试的触发方是发送
方任务本身,其 created_by 与派生任务的 created_by 天然相同
(派生时跟父任务走),所以 worker 侧只查发送方创建人这一道
approve_merge,效果与双人门在派生链内等价;同创建人-only
的目标同样只查这一道,权限查不到时则收紧到仅派生链内。
- wait 的超时口径与服务端 /wait 不同(见上文),数量口径已
对齐到 8 个;不要把标记 wait 的 3600 秒上限套到 HTTP /wait 上。
- 本批没有 MCP 真工具:标记工具永远是异步的(下一轮才拿到
报告),做不到同轮函数返回;想做同轮返回要另搭任务级鉴权的
MCP 服务器,不在本批。
- 签名门残留:worker 的 _code_signature 只签 worker / sandbox /
coordinator / cost / server / host 共 6 个文件,新模块
marker_tools.py 不在签名候选里。本批部署时 worker.py 与
sandbox.py 本身都变了,签名门照常生效;但将来只改
marker_tools.py 的补丁不会单独触发签名门,届时要么把
marker_tools.py 加进签名候选并同步改单测的 6 个断言,要么
接受这一处缺口——本批不顺手改签名,避免动无关安全测试的
预期。
- spawn 分支的未提交半成品没有原样合并进来:本批只移植了
已提交的 task-create / task-poll / task-read 实现与未提交的
wait 解析(收紧到 8 个后接通执行),handoff / worktree /
PR 检查 / automation / clock-sleep 的解析与 send-message-to-thread
别名均未移植;spawn 工作区本身未动。
## 关键实现位置
- cloud/marker_tools.py:纯逻辑(解析、校验、快照格式化、
门禁、派生代数与直接上限计数),单测不需要 worker/server。
- cloud/sandbox.py:本地 _result_from_runner 与 Docker
_parse_host_result 两处提取标记块,worker 侧按结果
dict 里的 task_creates / task_polls / wait_threads /
task_cancels / task_retries / todo_lists / todo_creates /
todo_updates 执行。
- cloud/worker.py:_deliver_marker_tools(5 处投递点,与
_deliver_peer_messages 同款位置)、_poll_task_snapshot、
_wait_task_targets、_queue_self_followup、_marker_auth。
- cloud/tests/test_marker_tools.py:解析上限与钳制、非法块跳过、
跨项目拒绝、自操作拒绝、已终态拒绝、goal / 已合并拒绝、
重试仅三态且清空旧结果、wait 多目标终态与超时快照、派生
深度与直接上限、权限检查失败收紧。
## 待办标记工具(todo-list / todo-create / todo-update)
与上面四类同款异步语义:写块 → 回合结束 → worker 执行 →
报告拼成后续消息、下一轮才回到父任务。goal 任务与协调员
任务不解析(只收不发),所以首轮待办注入也跳过 goal 任务,
避免模型写出永远不会执行的 todo-update 块。
### todo-list:查本项目待办
```text
```todo-list
{"status": "pending", "q": "文档"}
```
```
- 不传 status 时只返回未完成项(pending / in_progress /
blocked),查已完成项须显式传 status;q 是标题/描述子串。
- 每轮最多 5 块;status 非法、q 非字符串或超长时整块跳过。
- 读权限:发送方创建人须有项目 read 权限,否则报告里写清
拒绝;报告最多列 20 条,超出写明还有几条。
### todo-create:新建待办
```text
```todo-create
{"title": "写接口文档", "priority": "high"}
```
```
- title 必填 1-500 字;description 可选 <=20000 字;
status / priority 只能是枚举值;due_at 是 epoch 秒,
>=1e11 按毫秒除 1000;task_id 关联的任务必须在本项目内,
否则整块拒绝;sort_order 整数可选。
- 每轮最多 5 块;写权限:发送方创建人须有 create_task
权限,否则拒绝。
### todo-update:改待办(最常用是改状态)
```text
```todo-update
{"id": "<待办 id>", "status": "completed"}
```
```
- id 必填,至少改一个字段;各字段校验与 todo-create 同款,
未知字段忽略;行不存在或属于别的项目时按“不存在”拒绝,
不改任何状态;completed 记 completed_at,离开 completed
清掉(复用 todos 模块状态机)。
- 每轮最多 5 块;删除不走标记块(硬删除只在控制台 /
REST DELETE 里做,避免模型误删整条)。
### 首轮注入(worker _apply_todo_injection)
- 普通任务被认领的首轮,worker 在内存副本的 prompt 前贴
一份本项目未完成待办(最多 20 条、标题截断 100 字),附
三行 todo-update 示例;原 prompt 不落库,续跑与断点续跑
不重复贴,协调员与 goal 任务跳过,无未完成待办时不贴空
章节,注入查询失败时静默跳过、不炸认领。
- 注入贴的是数据,不是完整工具指南;完整格式只在本文件里。
## 任务列表标记(task-list)与项目注入
### task-list:查本项目任务
```text
```task-list
{"status": "failed", "q": "登录", "limit": 20}
```
```
- 不传 status 时查全部状态(与 todo-list 默认只查未完成
不同——分诊首先要看到失败与运行中的任务),报告附各状态
计数;q 是 prompt 子串;limit 钳制 1-50(默认 20),
超出写明还有几条;列表按新建倒序。
- 每轮最多 5 块;status 非法、q 非字符串或超长、limit 非
整数时整块跳过;只读,发送方创建人须有项目 read 权限,
否则报告里写清拒绝;跨项目任务不出现。
- 只读不改状态:捡到失败任务后改状态走 task-retry /
task-cancel,合并与审批仍由人操作。
### 首轮项目注入(worker _apply_project_injection)
- 普通任务与 goal 任务被认领的首轮,worker 在内存副本前
贴三行项目上下文:项目名、仓库(未绑定时明写未绑定,
不编地址)、环境(有 environment_id 时写环境名/分支/
状态,否则写无);再加一行 task-list 格式示例。
- goal 任务同时改内存里的 goal(执行器取 goal 优先于
prompt,只改 prompt 的话 goal 任务看不到);原 prompt /
goal 不落库,协调员、续跑与断点续跑跳过,项目行查不到
或查询失败时原样返回、不贴半截上下文、不炸认领。
- 贴的是数据加一行格式示例,不是完整工具指南;完整格式
只在本文件里。注入顺序:项目上下文在最前,其次待办、
记忆,最后是原 prompt。
## 成本、配额与文件标记(task-cost / quota-status / task-files)
### task-cost:查单个任务成本(只读)
```text
```task-cost
{"task_id": "<目标任务 id>"}
```
```
- 报告:美元数(只计有价行)、总 token(actual / estimated
分列)、轮次;无价 actual token 单独列,不会被静默算成
0 美元(与 CostStore._report 同款口径)。
- 门禁比服务端只要求 read 更严:目标须在本项目内,且在
派生链内或与发送方同创建人(自己也算同创建人),防止在
项目内猜 id 看别人的花费;每轮最多 5 块。
### quota-status:查发送方创建人当月配额(只读)
```text
```quota-status
{}
```
```
- 块体必须是 JSON 对象,不接受任何参数:配额永远按发送方
任务的创建人算(创建人缺失时回查父任务,仍查不到时拒绝),
块里写别人 id 也无效。报告:总额、已用、剩余、无价
actual token;显式不限额时明写不限额,不编一个数字。
- 每轮最多 5 块;发送方创建人须有项目 read 权限,否则拒绝。
### task-files:读派生链内或同创建人任务的文件(只读)
```text
```task-files
{"task_id": "<目标任务 id>", "path": "out.txt"}
```
```
- 不传 path(或空串)时列顶层(只列前 50 项,目录名带 /);
传相对路径读文件,上限 20000 字,超出写明截断。
- 门禁:目标须在本项目内且在派生链内或同创建人;cloud
任务与 worktree 在别机的任务拒绝(文件不在本机);目标
没有本地 worktree 时按“没有本地 worktree”报告,不编内容。
- 路径安全:绝对路径、.. 逃逸、符号链接逃逸一律拒绝并在
报告里写清;二进制文件(解码失败或含 NUL 字节)拒绝;
worktree 解析只接受已存在的任务级目录(租户路径或
host.py 同级 work/host-worktrees),绝不把共享挂载点本身
当成目标任务的目录。
- 每轮最多 5 块;删除、写文件不走标记块(模型改文件走它
自己的执行器工作区,不是读别人 worktree 的路)。