Files
codex-app-server/cloud/marker_tools.py
T

1060 lines
42 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。
设计(与 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 报告,不编内容。
上限口径(本模块最终定的值,文档与代码一致):
- 每轮创建、轮询、取消、重试各最多 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 的目标一律拒绝,并把原因写进报告——绝不
在查不到权限时静默放宽。
本模块只依赖标准库,不 import cloud 其它模块,避免循环
import。DB 助手函数只用 ? 占位符,与 peer_messages 同款写法。
"""
import json
import os
import re
import time
import uuid
MAX_CREATES_PER_TURN = 5
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
MAX_POLL_TIMEOUT_SECONDS = 3600
DEFAULT_SNAPSHOT_CHARS = 2000
MAX_SNAPSHOT_CHARS = 8000
TERMINAL_STATUSES = ("completed", "failed", "interrupted", "merged",
"merge-failed")
RETRYABLE_STATUSES = ("failed", "interrupted", "merge-failed")
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)
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 内相对路径的安全解析:与服务端
_safe_worktree_path 同款——绝对路径、.. 逃逸、符号链接
逃逸一律返回 None;空串返回 worktree 本身。"""
if not rel:
return worktree
if os.path.isabs(rel):
return None
base = os.path.realpath(worktree)
target = os.path.realpath(os.path.join(base, rel))
try:
if os.path.commonpath([base, target]) != base:
return None
except ValueError:
return None
return target
_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 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 任务判定:verify_command 非空或 goal_mode 为真。
与 worker run_one 的 goal 分支同款(verify_command 或
goal_mode);goal 字段本身是 goal_mode=1 时 prompt 的副本,
不单独当判定条件,避免把普通任务的 goal 文本误判。"""
if not task:
return False
return bool(task.get("verify_command") or task.get("goal_mode"))
def check_control_gate(target, sender, relation, kind):
"""task-cancel / task-retry 的纯函数门禁,返回 (ok, reason)。
kind 只能是 "cancel" 或 "retry",其它值一律拒绝。权限层
不在这里:worker 侧另查 create_task / approve_merge,见
模块头;本函数只管目标状态与关系,单测不需要 AuthStore。"""
if kind not in ("cancel", "retry"):
return False, "未知的控制操作"
if target is None:
return False, "目标任务不存在"
if (target.get("project_id") is not None
and sender.get("project_id") is not None
and target.get("project_id") != sender.get("project_id")):
return False, "跨项目任务不可操作"
if target.get("id") == sender.get("id"):
return False, "不能操作自己"
if is_goal_task(target):
return False, "goal 任务不可取消或重试(goal 循环自有轮次)"
status = target.get("status")
if kind == "cancel":
if status in TERMINAL_STATUSES:
return False, "目标已终态(%s),不可取消" % status
else:
if status not in RETRYABLE_STATUSES:
return False, ("仅 failed / interrupted / merge-failed"
" 可重试,当前状态为 %s" % status)
if relation not in ("descendant", "same_creator"):
return False, "目标不在发送方派生链上且创建人不同"
return True, ""
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