"""任务内标记工具(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 的目标一律拒绝,并把原因写进报告——绝不 在查不到权限时静默放宽。 本模块只依赖标准库,不 import cloud 其它模块,避免循环 import。DB 助手函数只用 ? 占位符,与 peer_messages 同款写法。 """ import json import os import re import time import uuid from datetime import datetime, timedelta, timezone 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 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) _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 内相对路径的安全解析:与服务端 _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 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 任务判定: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