Files
codex-app-server/cloud/marker_tools.py
T
host 41e8c98512 feat: cloud 门禁/超时/成本/沙箱收敛为单一判定源
- 新增标准库叶子层 timeouts/gates/quota/sandbox_policy:
  审批 3600 与单轮 600 各有名常量与环境覆盖层,已建项
  expires_at 永不重算;目录/控制/合并状态门禁统一判定,
  目录违规无任何覆盖参数;配额取数与比较唯一来源
  (USD 先于无价、命中均为 >=、无价只计 actual 行);
  /tmp 隔离矩阵与失联清理判定纯函数化。
- 调用点委托:server/worker/automations/auto_memory 的
  配额 SQL 与比较、server/tree/marker 的目录解析、
  marker 的控制门、sandbox 的任务容器名正则、两处 300 秒
  陈旧阈值默认值均收敛,别名与错误文案不变。
- 统一两个 P0 口径分歧:服务端无价计数由全部 usd IS NULL
  行改为 actual-only;显式不限额用户在服务端准入亦不受
  无价上限约束(与 worker 运行时一致)。
- 不新增门禁种类与 record 模式(主线无此实现);不改
  skill_seed/skills/SKILL.md;不改 v1.1 契约与分支保护。
- 新增 test_arch_converge.py 18 项。
2026-11-03 02:49:25 +00:00

1259 lines
51 KiB
Python
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):task-create / task-poll / task-read /
wait-threads / task-cancel / task-retry / todo-list / todo-create /
todo-update / task-list / task-cost / quota-status / task-files /
goal-convert / automation-create。
新增的两个工具是“LLM 自己判断意图、不用用户说关键词”
那条路:用户只说“我要一直收 xxx”“明天 9 点看 yyy”这种
自然语言,agent 在回合里写 goal-convert / automation-create
块,worker 在轮次边界执行——不是服务端再用正则猜用户
是不是在说 goal / 自动化。关键词解析(cloud/goal_command)
仍保留作显式命令兜底,两条路不互斥。
注意:本批刻意不向 LLM 自动注入这两个工具的指南(避免
prompt 臃肿),agent 知道它们的唯一途径是用户在会话里
手动告诉它,或调用方在任务 prompt 里自己写明;格式见
docs/marker-tools.md 新增的两节。
设计(与 peer_messages、task_spawn 同款原则,不另起一套):
- agent 不用联网、不用凭证:agent 只在回合输出里写标记块,
可信的 worker 在 completed 轮次后解析并执行——本地与
Docker 执行器都通(两者都经 sandbox 的结果解析提取标记块,
与 task-message 同款)。
- 异步语义:写块 → 回合结束 → worker 执行 → 报告拼成后续
消息、下一轮才回到父任务。不是同轮函数返回;轮询与等待
在 worker 执行期间循环查库、占 worker 槽位(与协调员等齐
同款代价),超时或父任务被取消时中止。
- 只收不发:goal 任务与协调员任务不解析这些标记块。本批
工具是给“普通任务当项目经理、自己派生员工”那条路用的,
不是给协调员 PM 用的——协调员任务本身不跑模型回合,等齐
循环也不会在中途排空后续消息做判断。
- 拒绝只记报告与审计、不炸任务、不改目标状态:非法 JSON、
超长、超上限、跨项目、自操作、已终态、goal、已合并、门禁
不过,全部按“跳过 + 报告里写清原因”处理。
四个工具的分工,不要混:
- task-create:新建一个平级主线任务(不是子代理,也不是
fork)。新任务 prompt 由标记块给,不继承父任务的历史;
环境基线继承父任务的 environment_id,但 worker 会给它独立
的 tenant/worktree,不动父任务的 cwd。父任务未提交的改动
不会自动带过去。模型/强度继承优先级:块里显式写 >
父任务的 child_model / child_effort(派生默认)> 父任务
自身的 model / effort > worker 默认(NULL 时由认领方补)。
子任务行上同时记下父任务的 child_model / child_effort,
这样隔代派生时派生默认仍然沿用,不会因为中间一层没写
就丢回 worker 默认。
- task-poll / task-read:按任务 id 轮询阅读。task-poll 按
interval 查库直到终态或超时;task-read 是同款别名,
不传 wait_seconds 时立即读一次快照,传了则等这么久再读
一次。read-thread / read_thread 是同款别名。
- wait-threads:一次等多个目标任务,并行查库(每轮查一次
全部目标,不是逐个串行等超时相加),全部终态或超时后把
每目标快照一起排回父任务。
- task-cancel / task-retry:对已存在的任务做取消 / 重试。
取消复用 worker 现有的取消路径语义(取消后状态为
interrupted);重试与服务端 POST /tasks/{id}/retry 对齐
(仅 failed / interrupted / merge-failed 可重试,重试时
清空上一轮 result_text / error_text / actual_model、状态
回 queued、prompt 与模型等原样保留)。
- todo-list / todo-create / todo-update:项目待办的读与写,
直接复用 todos 模块的校验与状态机(completed 记
completed_at、离开 completed 清掉)。todo-list 不传 status
时只返回未完成项(pending / in_progress / blocked),
查已完成项须显式传 status;写操作只在本项目内生效,
跨项目待办一律按“不存在”拒绝;删除不走标记块(硬删除
只在控制台 / REST DELETE 里做,避免模型误删整条)。
- task-list:列出本项目任务(新建倒序),让模型在不知道
任务 id 时也能分诊——查运行中与失败的任务、捡失败任务
重试。不传 status 时查全部状态并附各状态计数;只读,
跨项目任务不出现,报告最多列 limit 条(默认 20,上限 50)。
- task-cost / quota-status:只读成本与配额。task-cost 查单个
任务的成本(美元只计有价行,无价 token 单列);quota-status
查发送方任务创建人当月的配额、已用与无价 token——派生子
任务前先看配额,避免配额门在执行时才拦住。两者都不接受
查别人的参数:quota 永远按发送方创建人算,task-cost 只
放行派生链内或同创建人的目标(比服务端只要求 read 更严,
防止在项目内猜 id 看别人的花费)。
- task-files:读派生链内或同创建人任务的 worktree 文件。
不传 path 时列顶层,传相对路径读文件(上限 20000 字,
二进制文件拒绝);绝对路径、.. 逃逸、符号链接逃逸一律
拒绝;cloud 任务与 worktree 在别机的任务拒绝(文件不在
本机);目标没有本地 worktree 时按 404 报告,不编内容。
- goal-convert:把本会话在轮次边界转成新语义 goal 任务
(goal_mode=1、goal 即块里给的目标、无限轮直到模型自决
“目标已达成”,与 /goal 转换同款 CAS)。每轮最多 1 块;
实际执行时各块照常执行、报告先排、/goal 消息后排,转成
goal 后本任务不再解析标记块。
- automation-create:新建一个自动化定义(见
extract_automation_creates 的字段说明)。每轮最多 5 块;
创建人跟父任务走,须有项目 create_task 权限,配额超限
时整轮跳过;name 缺失时 worker 自动生成,避免重名撞号。
创建只是登记定义,到期执行仍走 automations 的 claim 门
与外部触发器,见 automations 模块。
上限口径(本模块最终定的值,文档与代码一致):
- 每轮创建、轮询、取消、重试各最多 5 块;todo-list、
todo-create、todo-update、task-list、task-cost、
quota-status、task-files 各最多 5 块;wait
每轮最多 2 块,每块最多 8 个目标。8 这个数是刻意与服务端
POST /tasks/{id}/wait 的“一次最多 8 个线程”对齐的;
spawn 分支原先的每块 10 个在此收紧到 8 个,不要再用 10。
- 超时口径与服务端 /wait 不同,且是故意的:服务端 /wait
是给外部调用方持 HTTP 连接等的,上限 120 秒;标记 wait
是 worker 在回合后直连库循环查,不持 HTTP 连接,超时
钳制与 task-poll 同款(0-3600 秒,0 = 立即快照),占的
是 worker 槽位而不是 HTTP 连接。两者不要混用、不要互抄
上限。
- 派生护栏:创建 prompt 1-20000 字,name 可选 1-100 字
(仅用于派生报告,tasks 表没有 name 列,不落库);
model 不做白名单;effort 必须是 codex 的 effort 档位;
executor 必须是 local / docker / cloud,未指定时继承父任务;
派生深度上限 3 代、单个父任务直接派生上限 20 个。
门禁(task-cancel / task-retry,纯函数层 + worker 权限层):
- 纯函数层(本模块 check_control_gate):目标存在、同项目、
非自己、非 goal、状态符合 kind(取消要求非终态,重试
要求仅三态)、关系为派生链内或同创建人,二选一满足即可。
“同项目猜到 id 就能取消任何人的任务”在此被挡住。
- worker 权限层(worker.py _deliver_marker_tools):取消要求
发送方创建人有项目 create_task 权限(与服务端取消口径
同款),重试要求有 approve_merge 权限(与服务端重试口
径同款)。权限检查本身抛异常时收紧到仅派生链内可用,
同创建人-only 的目标一律拒绝,并把原因写进报告——绝不
在查不到权限时静默放宽。
本模块只依赖标准库与同为标准库叶子的 cloud.gates /
cloud.timeouts,不 import 其他 cloud 模块,避免循环
import。DB 助手函数只用 ? 占位符,与 peer_messages 同款写法。
"""
import json
import os
import re
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from cloud.gates import RETRYABLE_STATUSES as _GATE_RETRYABLE
from cloud.gates import TERMINAL_STATUSES as _GATE_TERMINAL
from cloud.gates import decide_control as _gate_decide_control
from cloud.gates import is_goal_task as _gate_is_goal_task
from cloud.gates import resolve_inside as _gate_resolve_inside
from cloud.timeouts import LONG_TIMEOUT_SECONDS as _LONG_TIMEOUT
# ZoneInfo 在极老 Python 上不存在:先声明为 Any 再 try import,
# 这样 except 分支的 None 赋值不再是“给 type 赋值”(mypy
# misc + assignment),运行时仍是“有则类、无则 None”,
# 下游 `if ZoneInfo is not None` 守卫语义不变。
ZoneInfo: Any = None
try:
from zoneinfo import ZoneInfo
except Exception: # pragma: no cover - 极老 Python 无 zoneinfo
ZoneInfo = None
MAX_CREATES_PER_TURN = 5
MAX_GOAL_CONVERTS_PER_TURN = 1
MAX_AUTOMATION_CREATES_PER_TURN = 5
MIN_AUTOMATION_INTERVAL_SECONDS = 60
MAX_AUTOMATION_INTERVAL_SECONDS = 30 * 86400
MAX_AUTOMATION_REPO_URL_CHARS = 1000
DEFAULT_AUTOMATION_INTERVAL_SECONDS = 86400
MAX_POLLS_PER_TURN = 5
MAX_WAITS_PER_TURN = 2
MAX_WAIT_TARGETS_PER_BLOCK = 8
MAX_CONTROLS_PER_TURN = 5
MAX_PROMPT_CHARS = 20000
MAX_NAME_CHARS = 100
MAX_SPAWN_DEPTH = 3
MAX_DIRECT_CHILDREN = 20
DEFAULT_POLL_INTERVAL_SECONDS = 5
DEFAULT_POLL_TIMEOUT_SECONDS = 300
# 轮询上限与状态集收敛到 cloud/gates.py / cloud/timeouts.py
#(别名保留,调用点与测试 import 的名字不变)。
MAX_POLL_TIMEOUT_SECONDS = _LONG_TIMEOUT
DEFAULT_SNAPSHOT_CHARS = 2000
MAX_SNAPSHOT_CHARS = 8000
TERMINAL_STATUSES = _GATE_TERMINAL
RETRYABLE_STATUSES = _GATE_RETRYABLE
EFFORTS = ("low", "medium", "high", "xhigh", "max")
EXECUTORS = ("local", "docker", "cloud")
TODO_STATUSES = ("pending", "in_progress", "blocked", "completed",
"cancelled")
TODO_PRIORITIES = ("low", "medium", "high")
MAX_TODO_LISTS_PER_TURN = 5
MAX_TODO_CREATES_PER_TURN = 5
MAX_TODO_UPDATES_PER_TURN = 5
MAX_TODO_TITLE_CHARS = 500
MAX_TODO_DESCRIPTION_CHARS = 20000
MAX_TODO_QUERY_CHARS = 500
TASK_STATUSES = ("queued", "running", "completed", "failed",
"interrupted", "merged", "merge-failed")
MAX_TASK_LISTS_PER_TURN = 5
DEFAULT_TASK_LIST_LIMIT = 20
MAX_TASK_LIST_LIMIT = 50
MAX_TASK_COSTS_PER_TURN = 5
MAX_QUOTA_PER_TURN = 5
MAX_TASK_FILES_PER_TURN = 5
MAX_TASK_FILE_CHARS = 20000
MAX_TASK_FILE_LIST = 50
MAX_TASK_FILE_PATH_CHARS = 1000
_CREATE_RE = re.compile(r"```task-create\s*\n(.*?)\n```", re.DOTALL)
_POLL_RE = re.compile(
r"```(task-poll|task_poll|task-read|task_read|read-thread|read_thread)"
r"\s*\n(.*?)\n```", re.DOTALL)
_WAIT_RE = re.compile(r"```(wait-threads|wait_threads)\s*\n(.*?)\n```",
re.DOTALL)
_CANCEL_RE = re.compile(r"```(task-cancel|task_cancel)\s*\n(.*?)\n```",
re.DOTALL)
_RETRY_RE = re.compile(r"```(task-retry|task_retry)\s*\n(.*?)\n```",
re.DOTALL)
_TODO_LIST_RE = re.compile(
r"```(todo-list|todo_list)\s*\n(.*?)\n```", re.DOTALL)
_TODO_CREATE_RE = re.compile(
r"```(todo-create|todo_create)\s*\n(.*?)\n```", re.DOTALL)
_TODO_UPDATE_RE = re.compile(
r"```(todo-update|todo_update)\s*\n(.*?)\n```", re.DOTALL)
_TASK_LIST_RE = re.compile(
r"```(task-list|task_list)\s*\n(.*?)\n```", re.DOTALL)
_TASK_COST_RE = re.compile(
r"```(task-cost|task_cost)\s*\n(.*?)\n```", re.DOTALL)
_QUOTA_RE = re.compile(
r"```(quota-status|quota_status)\s*\n(.*?)\n```", re.DOTALL)
_TASK_FILES_RE = re.compile(
r"```(task-files|task_files)\s*\n(.*?)\n```", re.DOTALL)
_GOAL_CONVERT_RE = re.compile(
r"```(goal-convert|goal_convert)\s*\n(.*?)\n```", re.DOTALL)
_AUTOMATION_CREATE_RE = re.compile(
r"```(automation-create|automation_create)\s*\n(.*?)\n```",
re.DOTALL)
def _shanghai_tz():
"""无时区时间默认按 Asia/Shanghai 理解(用户时区)。
有 zoneinfo 时用它;没有时退回固定 UTC+8,避免把“明天 9 点”
按 UTC 算成北京时间 17 点。"""
if ZoneInfo is not None:
try:
return ZoneInfo("Asia/Shanghai")
except Exception:
pass
return timezone(timedelta(hours=8))
def parse_first_run_at(value):
"""解析 first_run_at,返回 epoch 秒(float)或 None(无效)。
接受:epoch 秒(int/float,毫秒级数字自动除 1000)、纯数字
字符串、ISO8601 字符串(带偏移量按偏移量算;无时区按
Asia/Shanghai 算;“YYYY-MM-DD HH:MM”这种 fromisoformat
能读的也算)。不判断“是否过去”——过去判断在 worker 层,
那里才能把“已过去”写进报告让 agent 下一轮纠正;纯解析层
只负责“是不是一个合法时刻”,否则 LLM 的一次笔误会连
报告都拿不到。
本函数只依赖标准库,server 解析 API 的 first_run_at 时
也复用它,避免两端对同一字段的解析不一致。"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
epoch = float(value)
elif isinstance(value, str):
s = value.strip()
if not s:
return None
try:
epoch = float(s)
except ValueError:
try:
dt = datetime.fromisoformat(
s.replace("Z", "+00:00").replace("z", "+00:00"))
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_shanghai_tz())
return dt.timestamp()
else:
return None
if epoch >= 1e11: # 毫秒级 epoch,秒级不可能这么大
epoch = epoch / 1000.0
if epoch <= 0 or epoch > 4102444800: # 2100-01-01 之后不认
return None
return epoch
def row_get(row, name):
"""与 peer_messages.row_get 同款容错取值。"""
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
def _as_int(value, default):
if isinstance(value, bool):
return default
if isinstance(value, int):
return value
if isinstance(value, float) and value.is_integer():
return int(value)
return default
def _clamp(value, low, high, default):
value = _as_int(value, default)
if value < low:
return low
if value > high:
return high
return value
def extract_task_creates(text):
"""提取 task-create 块;prompt 缺失/空串/超长、name 超长、
effort/executor 非法时整块跳过;每轮最多 5 块;正文里的围栏
会把块截断成非法 JSON 而被跳过(与 task-message 同款非贪婪
匹配)。"""
if not text:
return []
out = []
for match in _CREATE_RE.finditer(str(text)):
if len(out) >= MAX_CREATES_PER_TURN:
break
try:
payload = json.loads(match.group(1))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
continue
prompt = prompt.strip()
if len(prompt) > MAX_PROMPT_CHARS:
continue
name = payload.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
continue
name = name.strip()
if len(name) > MAX_NAME_CHARS:
continue
model = payload.get("model")
if model is not None:
if not isinstance(model, str) or not model.strip():
continue
model = model.strip()
effort = payload.get("effort")
if effort is not None and effort not in EFFORTS:
continue
executor = payload.get("executor")
if executor is not None and executor not in EXECUTORS:
continue
out.append({"name": name, "prompt": prompt, "model": model,
"effort": effort, "executor": executor})
return out
def _todo_due_at(value):
"""标记块 due_at 校验:与 todos._validate_due_at 同款语义
(epoch 秒,>=1e11 按毫秒除 1000);非法返回 (False, None),
不在本层抛异常——非法块整块跳过。"""
if value is None or value == "":
return True, None
if isinstance(value, bool) or not isinstance(value, (int, float)):
return False, None
value = float(value)
if value >= 1e11:
value = value / 1000.0
return True, value
def extract_todo_lists(text):
"""提取 todo-list 块;不传 status 时查未完成项(pending /
in_progress / blocked),不返回 completed / cancelled——
报告要短,查已完成项须显式传 status。status 非法、q 非
字符串或超长时整块跳过;每轮最多 5 块。"""
if not text:
return []
out = []
for match in _TODO_LIST_RE.finditer(str(text)):
if len(out) >= MAX_TODO_LISTS_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
status = payload.get("status")
if status is not None and status not in TODO_STATUSES:
continue
q = payload.get("q")
if q is not None:
if not isinstance(q, str) or not q.strip():
continue
q = q.strip()
if len(q) > MAX_TODO_QUERY_CHARS:
continue
out.append({"status": status, "q": q})
return out
def extract_todo_creates(text):
"""提取 todo-create 块;title 必填 1-500 字,description
可选 <=20000 字,status / priority 只能是枚举值,task_id
只能是字符串,due_at 按 _todo_due_at 校验;任一项非法时
整块跳过;每轮最多 5 块。"""
if not text:
return []
out = []
for match in _TODO_CREATE_RE.finditer(str(text)):
if len(out) >= MAX_TODO_CREATES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
title = payload.get("title")
if not isinstance(title, str) or not title.strip():
continue
title = title.strip()
if len(title) > MAX_TODO_TITLE_CHARS:
continue
description = payload.get("description")
if description is not None:
if not isinstance(description, str):
continue
if len(description) > MAX_TODO_DESCRIPTION_CHARS:
continue
status = payload.get("status")
if status is not None and status not in TODO_STATUSES:
continue
priority = payload.get("priority")
if priority is not None and priority not in TODO_PRIORITIES:
continue
task_id = payload.get("task_id")
if task_id is not None:
if not isinstance(task_id, str):
continue
task_id = task_id.strip() or None
ok, due_at = _todo_due_at(payload.get("due_at"))
if not ok:
continue
sort_order = payload.get("sort_order")
if sort_order is not None and (
isinstance(sort_order, bool)
or not isinstance(sort_order, int)):
continue
out.append({"title": title, "description": description,
"status": status, "priority": priority,
"task_id": task_id, "due_at": due_at,
"sort_order": sort_order})
return out
def extract_todo_updates(text):
"""提取 todo-update 块;id 必填,至少改一个字段;各字段
校验与 todo-create 同款(status / priority 枚举、title
1-500 字、description <=20000 字、due_at 毫秒容忍、
sort_order 整数);任一项非法或无有效字段时整块跳过;
每轮最多 5 块。未知字段忽略,不因此跳过。"""
if not text:
return []
out = []
for match in _TODO_UPDATE_RE.finditer(str(text)):
if len(out) >= MAX_TODO_UPDATES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
todo_id = payload.get("id")
if not isinstance(todo_id, str) or not todo_id.strip():
continue
fields = {}
if "title" in payload:
title = payload["title"]
if not isinstance(title, str) or not title.strip():
continue
title = title.strip()
if len(title) > MAX_TODO_TITLE_CHARS:
continue
fields["title"] = title
if "description" in payload:
description = payload["description"]
if not isinstance(description, str):
continue
if len(description) > MAX_TODO_DESCRIPTION_CHARS:
continue
fields["description"] = description
if "status" in payload:
if payload["status"] not in TODO_STATUSES:
continue
fields["status"] = payload["status"]
if "priority" in payload:
if payload["priority"] not in TODO_PRIORITIES:
continue
fields["priority"] = payload["priority"]
if "task_id" in payload:
task_id = payload["task_id"]
if task_id is not None and not isinstance(task_id, str):
continue
fields["task_id"] = (task_id.strip() or None
if isinstance(task_id, str) else None)
if "due_at" in payload:
ok, due_at = _todo_due_at(payload["due_at"])
if not ok:
continue
fields["due_at"] = due_at
if "sort_order" in payload:
sort_order = payload["sort_order"]
if sort_order is not None and (
isinstance(sort_order, bool)
or not isinstance(sort_order, int)):
continue
if sort_order is not None:
fields["sort_order"] = sort_order
if not fields:
continue
out.append({"id": todo_id.strip(), "fields": fields})
return out
def extract_task_lists(text):
"""提取 task-list 块;不传 status 时查全部状态(与 todo-list
默认只查未完成不同——任务分诊首先要看到失败与运行中的,
全部状态的计数与截断报告才是可用的概览)。q 是 prompt
子串,limit 钳制 1-50(默认 20);status 非法、q 非字符
串或超长、limit 非整数时整块跳过;每轮最多 5 块。"""
if not text:
return []
out = []
for match in _TASK_LIST_RE.finditer(str(text)):
if len(out) >= MAX_TASK_LISTS_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
status = payload.get("status")
if status is not None and status not in TASK_STATUSES:
continue
q = payload.get("q")
if q is not None:
if not isinstance(q, str) or not q.strip():
continue
q = q.strip()
if len(q) > MAX_TODO_QUERY_CHARS:
continue
limit = payload.get("limit")
if limit is None:
limit = DEFAULT_TASK_LIST_LIMIT
elif isinstance(limit, bool) or not isinstance(limit, int):
continue
else:
limit = max(1, min(MAX_TASK_LIST_LIMIT, limit))
out.append({"status": status, "q": q, "limit": limit})
return out
def extract_task_costs(text):
"""提取 task-cost 块:{\"task_id\": \"...\"},与 task-cancel
同款别名口径(task_id / to / to_task_id / thread_id);
每轮最多 5 块。"""
return _extract_controls(text, _TASK_COST_RE)
def extract_quota_status(text):
"""提取 quota-status 块:块体必须是 JSON 对象(可为空对象),
不接受任何参数——配额永远按发送方任务的创建人算,块里写
别人 id 也无效,避免用标记块查别人的配额;每轮最多 5 块
(查询便宜,但多块只是重复报告)。"""
if not text:
return []
out = []
for match in _QUOTA_RE.finditer(str(text)):
if len(out) >= MAX_QUOTA_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
out.append({})
return out
def extract_task_files(text):
"""提取 task-files 块:task_id 必填,path 可选(默认列顶层);
path 非字符串或超长时整块跳过;绝对路径与 .. 逃逸不在此
层跳过——worker 的 safe_worktree_path 会拒绝并在报告里写
清原因,让模型知道错在哪;每轮最多 5 块。"""
if not text:
return []
out = []
for match in _TASK_FILES_RE.finditer(str(text)):
if len(out) >= MAX_TASK_FILES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
task_id = payload.get("task_id")
if not isinstance(task_id, str) or not task_id.strip():
continue
path = payload.get("path", "")
if path is None:
path = ""
if not isinstance(path, str):
continue
if len(path) > MAX_TASK_FILE_PATH_CHARS:
continue
out.append({"task_id": task_id.strip(), "path": path})
return out
def safe_worktree_path(worktree, rel):
"""worktree 内相对路径的安全解析:判定收敛到 cloud/gates.py
(与服务端 _safe_worktree_path 同款;绝对路径、.. 逃逸、
符号链接逃逸一律返回 None;空串返回 worktree 本身)。"""
return _gate_resolve_inside(worktree, rel)
_SPAWN_SUBJECT_ZH = ("新建的任务", "派生任务", "新建任务",
"子任务", "子代理", "新任务", "新会话")
_SPAWN_SUBJECT_EN = ("child tasks", "spawned tasks", "new tasks",
"new sessions", "subtasks", "subtask",
"child task", "new task", "new session")
def _effort_after(text, start):
"""在 model 后的同一子句里找强度:先找“强度/effort + 值”,
再找紧跟 model 的裸强度词(如“gpt-6-luna max”)。子句以
句号/换行/分号截断,跨子句的强度词不算,避免把下一句的
词误当成这一句的强度。"""
clause = str(text)[start:start + 60]
for stop in ("。", "\n", ";", ";"):
cut = clause.find(stop)
if cut >= 0:
clause = clause[:cut]
labelled = re.search(
r"(?:强度|effort)\s*[::=\s]\s*(low|medium|high|xhigh|max)",
clause, re.IGNORECASE)
if labelled:
return labelled.group(1).lower()
trailing = re.search(
r"^\s*[,,、\s]*(low|medium|high|xhigh|max)\b",
clause, re.IGNORECASE)
if trailing:
return trailing.group(1).lower()
return None
def _model_from_match(text, match, token_group):
"""从一次正则命中里取 (model, effort):model 词本身若是
强度词(如“子任务用 max”),只当强度、不当模型;模型词
尾部的句点是句子标点、不是模型名的一部分,要去掉。"""
token = match.group(token_group).rstrip(".")
if not token:
return None, None
if token.lower() in EFFORTS:
return None, token.lower()
return token, _effort_after(text, match.end(token_group))
def parse_spawn_defaults(text):
"""从主线 prompt 里解析派生默认模型/强度,只认显式指令。
认的句式(主语必须是派生对象,裸说“主线用 X”不算,
那是本任务自己的模型、应走建任务的 model 字段):
- 中文:子任务/子代理/派生任务/新建任务/新任务/新会话 +
用/使用/采用/模型:/模型是 + 模型词,可跟强度词或
“强度/effort + 强度词”,如“子代理用 gpt-6-luna max”、
“子任务模型:gpt-6-luna,强度:max”。
- 英文:subtask/child task/spawned task/new task/session +
use/using/with model/model: + 模型词,如
“subtasks use gpt-6-luna with effort max”。
- 中文倒装:用 + 模型词 + … + 子任务/子代理,如
“用 gpt-6-luna max 跑子任务”。
只取第一条命中;模型词只认 ASCII 词(中转模型 id 都是
ASCII),“用默认模型”这类中文词不会被误当成模型。
显式传了 child_model / child_effort 的任务不走本函数,
本函数只在建任务时两者缺省才作兜底,且结果落在任务行
的 child_model / child_effort 上、调用方可直接检查。
返回 dict,只含解析到的键(都没解析到时为空 dict)。"""
if not text:
return {}
text = str(text)
zh_subject = "|".join(_SPAWN_SUBJECT_ZH)
zh_pattern = re.compile(
r"(?:%s)[^。\n;;]{0,50}?(?:用\s*|使用\s*|采用\s*"
r"|模型\s*[::]\s*|模型\s*是\s*)"
r"([A-Za-z][A-Za-z0-9._-]{1,63})" % zh_subject)
match = zh_pattern.search(text)
if match:
model, effort = _model_from_match(text, match, 1)
out = {}
if model:
out["model"] = model
if effort:
out["effort"] = effort
if out:
return out
en_subject = "|".join(_SPAWN_SUBJECT_EN)
en_pattern = re.compile(
r"(?:%s)\b[^.\n]{0,50}?(?:use\s+|using\s+|with\s+model\s+"
r"|model\s*[:=]\s*)"
r"([A-Za-z][A-Za-z0-9._-]{1,63})" % en_subject,
re.IGNORECASE)
match = en_pattern.search(text)
if match:
model, effort = _model_from_match(text, match, 1)
out = {}
if model:
out["model"] = model
if effort:
out["effort"] = effort
if out:
return out
rev_pattern = re.compile(
r"(?:用|使用|采用)\s*([A-Za-z][A-Za-z0-9._-]{1,63})"
r"[^。\n;;]{0,30}?(?:%s)" % zh_subject)
match = rev_pattern.search(text)
if match:
model, effort = _model_from_match(text, match, 1)
out = {}
if model:
out["model"] = model
if effort:
out["effort"] = effort
if out:
return out
return {}
def extract_task_polls(text):
"""提取 task-poll / task-read(含 read-thread 别名)块。
task-read 不传 wait_seconds 时立即读一次快照(timeout=0);
传了则等这么久再读一次。interval 钳制 1-60 秒,timeout
钳制 0-3600 秒,chars 钳制 200-8000 字;各类块合计每轮
最多 5 块,多余的忽略。"""
if not text:
return []
out = []
for match in _POLL_RE.finditer(str(text)):
if len(out) >= MAX_POLLS_PER_TURN:
break
kind, raw = match.group(1), match.group(2)
try:
payload = json.loads(raw)
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
task_id = payload.get("task_id") or payload.get("to") \
or payload.get("to_task_id")
if not isinstance(task_id, str) or not task_id.strip():
continue
chars = _clamp(payload.get("chars"), 200, MAX_SNAPSHOT_CHARS,
DEFAULT_SNAPSHOT_CHARS)
if kind in ("task-read", "task_read", "read-thread",
"read_thread"):
wait = payload.get("wait_seconds")
if wait is None:
interval = DEFAULT_POLL_INTERVAL_SECONDS
timeout = 0
else:
timeout = _clamp(wait, 0, MAX_POLL_TIMEOUT_SECONDS, 0)
interval = timeout if timeout > 0 \
else DEFAULT_POLL_INTERVAL_SECONDS
else:
interval = _clamp(payload.get("interval_seconds"), 1, 60,
DEFAULT_POLL_INTERVAL_SECONDS)
timeout = _clamp(payload.get("timeout_seconds"), 0,
MAX_POLL_TIMEOUT_SECONDS,
DEFAULT_POLL_TIMEOUT_SECONDS)
out.append({"task_id": task_id.strip(),
"interval_seconds": interval,
"timeout_seconds": timeout, "chars": chars})
return out
def extract_wait_threads(text):
"""提取 wait-threads / wait_threads 块:一次等多个目标。
块格式::
```wait-threads
{"task_ids": ["B", "C"], "interval_seconds": 5,
"timeout_seconds": 300, "chars": 2000}
```
task_ids 也接受 thread_ids / threads 两个别名。去重保序,
每块最多 8 个目标(与服务端 /wait 口径对齐,见模块头),
每轮最多 2 块;无有效 id 的块跳过。interval 钳制 1-60 秒,
timeout 钳制 0-3600 秒(worker 直连库口径,见模块头),
chars 钳制 200-8000 字。"""
if not text:
return []
out = []
for match in _WAIT_RE.finditer(str(text)):
if len(out) >= MAX_WAITS_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
raw_ids = (payload.get("task_ids") or payload.get("thread_ids")
or payload.get("threads"))
if not isinstance(raw_ids, list):
continue
ids = []
for tid in raw_ids:
if isinstance(tid, str) and tid.strip() \
and tid.strip() not in ids:
ids.append(tid.strip())
if not ids:
continue
ids = ids[:MAX_WAIT_TARGETS_PER_BLOCK]
out.append({
"task_ids": ids,
"interval_seconds": _clamp(
payload.get("interval_seconds"), 1, 60,
DEFAULT_POLL_INTERVAL_SECONDS),
"timeout_seconds": _clamp(
payload.get("timeout_seconds"), 0,
MAX_POLL_TIMEOUT_SECONDS, DEFAULT_POLL_TIMEOUT_SECONDS),
"chars": _clamp(payload.get("chars"), 200,
MAX_SNAPSHOT_CHARS, DEFAULT_SNAPSHOT_CHARS),
})
return out
def _extract_controls(text, regex):
"""提取 task-cancel / task-retry 块的公共逻辑。
task_id 缺失/空串时整块跳过;to / to_task_id / thread_id
是同款别名;每种每轮最多 5 块,多余的忽略。"""
if not text:
return []
out = []
for match in regex.finditer(str(text)):
if len(out) >= MAX_CONTROLS_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
task_id = (payload.get("task_id") or payload.get("to")
or payload.get("to_task_id")
or payload.get("thread_id"))
if not isinstance(task_id, str) or not task_id.strip():
continue
out.append({"task_id": task_id.strip()})
return out
def extract_task_cancels(text):
"""提取 task-cancel 块:{"task_id": "..."}。"""
return _extract_controls(text, _CANCEL_RE)
def extract_task_retries(text):
"""提取 task-retry 块:{"task_id": "..."}。"""
return _extract_controls(text, _RETRY_RE)
def extract_goal_converts(text):
"""提取 goal-convert / goal_convert 块:把本会话转成 goal。
块格式::
```goal-convert
{"goal": "收 xxx 到 yyy 为止"}
```
goal 必填、1-20000 字;每轮最多 1 块(转一次就够了,
多余的忽略)。不接受 verify_command / goal_max_rounds:
LLM 定的 goal 一律是新语义——无限轮直到模型自己输出
“目标已达成”,与服务端 /goal 转换口径一致,绝不让 LLM
顺手把旧验收语义混进来。"""
if not text:
return []
out = []
for match in _GOAL_CONVERT_RE.finditer(str(text)):
if len(out) >= MAX_GOAL_CONVERTS_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
goal = payload.get("goal") or payload.get("target")
if not isinstance(goal, str) or not goal.strip():
continue
goal = goal.strip()
if len(goal) > MAX_PROMPT_CHARS:
continue
out.append({"goal": goal})
return out
def extract_automation_creates(text):
"""提取 automation-create / automation_create 块。
块格式::
```automation-create
{"name": "看 xxx 每日", "prompt": "查看 xxx 最新并汇总",
"interval_seconds": 86400,
"first_run_at": "2026-10-30T09:00:00+08:00",
"once": false, "bind": null, "repo_url": null}
```
- prompt 必填、1-20000 字;name 可选、1-100 字,缺失时
worker 自动生成(LLM 不必为起名费劲,也不会因重名
撞上同项目已有的 active 自动化)。
- interval_seconds 可选、缺省 86400,必须是整数且在
60-30*86400 之间;非法时整块跳过,不静默钳制——钳制
会把“每小时”悄悄改成“每分钟下限”或把“每月”改
短,调度错误比拒绝更贵。
- first_run_at 可选,见 parse_first_run_at;不给表示
建完即到期(沿用自动化原有语义),给了表示首次在该
时刻跑——“每天 9 点”要写成 first_run_at=下一个 9 点
+ interval_seconds=86400,而不是只写间隔(只写间隔
会从创建时间起算,每天跑在创建时间那个钟点)。
- once 可选、缺省 false;true 表示只跑一次(跑过后
is_due / due / claim 三处都不再到期,见 automations)。
- bind 可选、缺省 null(到期独立跑新任务);"heartbeat"
表示绑到本会话,到期唤醒本线程;"cron" 与 null 同款,
只是显式写明独立跑。其它值整块跳过。
- repo_url 可选、最多 1000 字。
- 每轮最多 5 块,多余的忽略;非法 JSON、prompt 缺失/
空串/超长时整块跳过。"""
if not text:
return []
out = []
for match in _AUTOMATION_CREATE_RE.finditer(str(text)):
if len(out) >= MAX_AUTOMATION_CREATES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
prompt = payload.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
continue
prompt = prompt.strip()
if len(prompt) > MAX_PROMPT_CHARS:
continue
name = payload.get("name")
if name is not None:
if not isinstance(name, str) or not name.strip():
continue
name = name.strip()
if len(name) > MAX_NAME_CHARS:
continue
interval = payload.get("interval_seconds")
if interval is None:
interval = DEFAULT_AUTOMATION_INTERVAL_SECONDS
elif isinstance(interval, bool):
continue
elif isinstance(interval, float) and interval.is_integer():
interval = int(interval)
elif not isinstance(interval, int):
continue
if interval < MIN_AUTOMATION_INTERVAL_SECONDS or \
interval > MAX_AUTOMATION_INTERVAL_SECONDS:
continue
first_run_at = None
if payload.get("first_run_at") is not None:
first_run_at = parse_first_run_at(payload.get("first_run_at"))
if first_run_at is None:
continue
once = payload.get("once", False)
if not isinstance(once, bool):
continue
bind = payload.get("bind", payload.get("bind_mode"))
if bind is not None and bind not in ("heartbeat", "cron"):
continue
repo_url = payload.get("repo_url")
if repo_url is not None:
if not isinstance(repo_url, str):
continue
repo_url = repo_url.strip() or None
if repo_url is not None and \
len(repo_url) > MAX_AUTOMATION_REPO_URL_CHARS:
continue
out.append({"name": name, "prompt": prompt,
"interval_seconds": interval,
"first_run_at": first_run_at, "once": once,
"bind": bind, "repo_url": repo_url})
return out
def parent_spawn_generation(conn, parent_id):
"""父任务自身的派生代数:普通任务 0,第一代派生任务 1,依此类推。
只数 parent_task_id 非空且 coordinator_part 为空的祖先链(即
派生链);协调员子任务(coordinator_part 非空)本身不是派生
任务,代数为 0,链到此为止。走 32 步仍未到头时按已数到的算,
绝不死循环。"""
generation = 0
cur = parent_id
for _ in range(32):
if not cur:
break
row = conn.execute(
"SELECT parent_task_id, coordinator_part FROM tasks"
" WHERE id = ?", (cur,)).fetchone()
if row is None:
break
parent = row_get(row, "parent_task_id")
part = row_get(row, "coordinator_part")
if parent is not None and part is None:
generation += 1
cur = parent
continue
break
return generation
def count_direct_spawn_children(conn, parent_id):
"""单个父任务的直接派生子任务数(不含协调员子任务)。"""
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE parent_task_id = ?"
" AND coordinator_part IS NULL", (parent_id,)).fetchone()
try:
return int(row[0]) if row is not None else 0
except (TypeError, ValueError, IndexError):
return 0
def fetch_task_snapshot(conn, task_id):
"""取目标任务的快照行;不存在时回 None。
用 SELECT * 而不是列清单:老库/最小测试表缺列时 row_get
容错回 None,不炸;门禁要用的 created_by / parent_task_id /
coordinator_part / verify_command / goal_mode 一并带上。"""
row = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
if row is None:
return None
return {"id": row_get(row, "id"),
"project_id": row_get(row, "project_id"),
"status": row_get(row, "status"),
"result_text": row_get(row, "result_text"),
"error_text": row_get(row, "error_text"),
"executor": row_get(row, "executor"),
"model": row_get(row, "model"),
"actual_model": row_get(row, "actual_model"),
"turn_id": row_get(row, "turn_id"),
"last_heartbeat": row_get(row, "last_heartbeat"),
"worktree_host": row_get(row, "worktree_host"),
"pr_url": row_get(row, "pr_url"),
"pr_number": row_get(row, "pr_number"),
"environment_id": row_get(row, "environment_id"),
"created_by": row_get(row, "created_by"),
"parent_task_id": row_get(row, "parent_task_id"),
"coordinator_part": row_get(row, "coordinator_part"),
"verify_command": row_get(row, "verify_command"),
"goal_mode": row_get(row, "goal_mode")}
def ancestry_relation(conn, sender, target):
"""发送方与目标的关系:descendant / same_creator / other。
descendant:沿目标的 parent_task_id 链(最多 32 步)能追溯
到发送方(直接派生、隔代派生都算;协调员子任务的链到
协调员为止,不会穿过协调员再往上算——因为协调员子任务的
coordinator_part 非空,链在此处按派生链定义截断,见
parent_spawn_generation 的同款处理;但本函数走的是
parent_task_id 原始链,目标本身若是协调员子任务、其父
是协调员时,发送方若恰好是该协调员也会算 descendant——
协调员任务不解析标记块,所以发送方不可能是协调员,
这一支在实际调用里到不了,不影响门禁)。
same_creator:链上追不到,但目标 created_by 与发送方
created_by 相同且非空。
other:两者都不是。"""
sender_id = (sender or {}).get("id")
target_parent = (target or {}).get("parent_task_id")
cur = target_parent
for _ in range(32):
if not cur:
break
if cur == sender_id:
return "descendant"
row = conn.execute(
"SELECT parent_task_id FROM tasks WHERE id = ?",
(cur,)).fetchone()
if row is None:
break
cur = row_get(row, "parent_task_id")
sender_creator = (sender or {}).get("created_by")
target_creator = (target or {}).get("created_by")
if sender_creator and target_creator \
and sender_creator == target_creator:
return "same_creator"
return "other"
def is_goal_task(task):
"""goal 任务判定:薄壳,判定在 cloud/gates.py。"""
return _gate_is_goal_task(task)
def check_control_gate(target, sender, relation, kind):
"""task-cancel / task-retry 的纯函数门禁,返回 (ok, reason)。
薄壳:判定在 cloud/gates.py(权限层仍在 worker 侧,
本函数只管目标状态与关系,单测不需要 AuthStore)。"""
return _gate_decide_control(target, sender, relation, kind)
def _heartbeat_age_text(snapshot, now=None):
"""最后心跳距今秒数,无心跳时回空串(不编造)。"""
hb = snapshot.get("last_heartbeat")
if hb is None:
return ""
try:
age = int((now if now is None else now) - float(hb))
except (TypeError, ValueError):
return ""
if age < 0:
age = 0
return "%d 秒前" % age
def _progress_lines(snapshot, now=None):
"""快照的进度行:执行器、模型、工作树主机、心跳、turn。"""
lines = []
if snapshot.get("executor"):
lines.append("执行器:%s" % snapshot["executor"])
model = snapshot.get("actual_model") or snapshot.get("model")
if model:
lines.append("模型:%s" % model)
if snapshot.get("worktree_host"):
lines.append("工作树主机:%s" % snapshot["worktree_host"])
age = _heartbeat_age_text(snapshot, now=now)
if age:
lines.append("最后心跳:%s" % age)
if snapshot.get("turn_id"):
lines.append("回合:%s" % snapshot["turn_id"])
return lines
def format_snapshot(snapshot, chars, timed_out=False, cancelled=False):
"""把快照格式化成给父任务下一轮的报告正文(截断防上下文爆炸)。"""
task_id = snapshot["id"]
status = snapshot["status"] or "?"
lines = ["[任务 %s]" % task_id, "状态:%s" % status]
lines.extend(_progress_lines(snapshot))
if cancelled:
lines.append("说明:等待期间父任务被取消,轮询已中止。")
elif timed_out:
lines.append("说明:等待超时,目标任务仍未终态,可再轮询一次。")
elif status in TERMINAL_STATUSES:
result = (snapshot["result_text"] or "").strip()
error = (snapshot["error_text"] or "").strip()
if result:
lines.append("结果:%s" % result[:chars])
if error:
lines.append("错误:%s" % error[:chars])
if not result and not error:
lines.append("结果:(空)")
else:
lines.append("说明:目标任务仍在运行或排队,可再轮询一次。")
return "\n".join(lines)
def new_child_id():
return uuid.uuid4().hex
def marker_report_header():
return "[任务工具报告]"
def format_cost(task_id, report):
"""成本单行:美元数只覆盖有价行,无价 actual token 单独列,
不会被静默算成 0 美元(与 CostStore._report 同款口径)。"""
usd = (report.get("usd_micros") or 0) / 1000000.0
line = ("任务 %s 成本:$%.4f,总 token %d(actual %d,"
"estimated %d),轮次 %d"
% (task_id, usd, report.get("total_tokens", 0),
report.get("actual_tokens", 0),
report.get("estimated_tokens", 0),
report.get("turns", 0)))
unpriced = report.get("unpriced_actual_tokens", 0)
if unpriced:
line += ",其中无价 actual token %d 未计入美元" % unpriced
return line
def format_todo(todo, max_title=100):
"""待办单行(报告与首轮注入共用):状态 + 标题(截断)+ id +
优先级,due_at 有值时附 UTC 时间;标题里的换行压成空格,
避免一行待办撑开多行报告。"""
title = str(todo.get("title") or "").replace("\n", " ").strip()
if len(title) > max_title:
title = title[:max_title] + "…"
parts = ["• [%s] %s (id=%s, priority=%s)"
% (todo.get("status"), title, todo.get("id"),
todo.get("priority"))]
due_at = todo.get("due_at")
if due_at:
try:
parts.append("due=%s" % time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(due_at))))
except (TypeError, ValueError, OverflowError):
pass
task_id = todo.get("task_id")
if task_id:
parts.append("task_id=%s" % task_id)
return " ".join(parts)
def format_task(task, max_prompt=80):
"""任务单行(task-list 报告用):状态 + id + 创建人 + prompt
截断;prompt 里的换行压成空格,避免一行任务撑开多行报告;
创建时间有值时附 UTC 时间。"""
prompt = str(task.get("prompt") or "").replace("\n", " ").strip()
if len(prompt) > max_prompt:
prompt = prompt[:max_prompt] + "…"
line = ("• [%s] %s (by=%s)"
% (task.get("status"), task.get("id"),
task.get("created_by") or ""))
created_at = task.get("created_at")
if created_at:
try:
line += " created=%s" % time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(created_at)))
except (TypeError, ValueError, OverflowError):
pass
if prompt:
line += " " + prompt
return line