Coordinator parts gain three enforced bounds, validated in validate_parts and carried through the coordinator_parts JSON (no schema change): timeout_seconds (1..86400, per-attempt wall clock from coordinator start; the coordinator poll loop cancels only that child with the same cancel mechanism as the global timeout, other parts keep waiting, audit coordinator_child_bound_exceeded), on_failure fail|tolerate (tolerated failures do not fail the coordinator, are named in the report head, and their failed children are not requeued on coordinator re-run while interrupted children still are), and result_chars (0..2000, default 500, enforced in build_report; the 4000-char report writeback cap still applies). evaluate_outcome is the single outcome rule used by both build_report and the worker error text, so tolerated failures never leak into the coordinator failure reason. Unknown part names fail closed. Deliberately NOT added, after reading the execution layer: per-part token/cost budget (cost is recorded only on completion, so a running child's spend is unobservable and creator quota is monthly, not per part), per-part round cap (normal tasks have no per-task round cap in the worker), and write-scope isolation (sandbox is per-project worktree, a scope string in a prompt would be an instruction, not a guarantee). Tests: test_delegation_bounds.py (8 tests).
314 lines
14 KiB
Python
314 lines
14 KiB
Python
"""协调员:把一个任务拆成多个并行子任务、等齐、合流汇总。
|
||
|
||
设计(与现有任务模型对齐,不另起一套执行器):
|
||
|
||
- 协调员本身是一个云端任务:建任务时传 coordinator_parts(分工
|
||
列表),它不跑模型回合;worker 认领后按分工一次建齐子任务
|
||
(普通任务,并行被各 worker 认领),等全部终态后合流成一份
|
||
汇总报告写回协调员任务。
|
||
- 子任务是普通任务:配额门、key 门、执行器过滤、取消语义全部
|
||
走现有链路,协调员不构成绕过;子任务带 parent_task_id 与
|
||
coordinator_part(分工名),控制台可用 parent_task_id 查到它们。
|
||
- 幂等与续跑:协调员任务重跑(重试/续跑)时按分工名复用已有
|
||
子任务,不重复建;已完成的子任务直接复用,失败/中断的回
|
||
queued 重跑——重试协调员即重试未完成的分工。
|
||
- 取消与超时:协调员被取消时尽力取消未终态的子任务;等齐
|
||
超时时同样取消挂起的子任务并标 failed,报告里写清哪几项
|
||
没完成。取消路径的协调员标 interrupted,不发完成/失败推送
|
||
(与普通任务的取消口径一致)。
|
||
- 孤儿收口(P1):协调员本身死亡(worker 进程死、VM 回收)
|
||
时没有“取消者”替它取消子任务,正在跑的子任务会无声继续
|
||
烧钱。worker 侧两道收口:① 协调员取消/超时终局时,cancel
|
||
之后仍未终态的子任务由 _coord_mark_orphans 直接标
|
||
interrupted 并记审计;② 常驻循环每轮的
|
||
sweep_orphaned_coordinator_children 把“父已终态(或父行
|
||
已失踪)但子仍未终态”的子任务统一标 interrupted 并记审计
|
||
(父仍在跑时绝不动子任务)。本模块只提供纯函数
|
||
nonterminal_children 供 worker 判断,DB 写回在 worker 侧。
|
||
|
||
- 有界委派(v5,Foundry“有界 subagent 委派”的对应物):
|
||
每个分工除 name/prompt/model/effort/executor 外,还可带
|
||
三个显式边界,全部在 validate_parts 校验、经 DB 的
|
||
coordinator_parts JSON 原样流转、由 worker 的协调员循环
|
||
真正执行,不是只写进 prompt 的愿望:
|
||
① timeout_seconds:该分工单次委派的墙钟上限,从协调员
|
||
本轮开始计时,超时由协调员循环直接 cancel 该子任务
|
||
(与全局超时同款 cancel 机制),其他分工继续等,不因
|
||
一个慢分工拖住整批到全局超时;② on_failure:fail(默认,
|
||
旧行为,任一非容忍分工未完成则协调员失败)或 tolerate
|
||
(该分工失败不拖垮协调员,汇总里点名已容忍,协调员
|
||
重跑时其 failed 子任务不再重排、interrupted 的仍重排,
|
||
因为后者可能是父任务取消所致、未获公平一轮);③
|
||
result_chars:回传给父任务的单项输出上限,默认 500,
|
||
汇总报告本身仍受 worker 写回的 4000 字上限约束。
|
||
三个边界的执行点都在协调员层:超时在循环里 cancel,
|
||
容忍在合流评价里算,截断在汇总报告里切。
|
||
|
||
- v5 明确不宣称的边界(查代码后的诚实口径,勿以字段
|
||
冒充执行):① 每分工 token/费用预算:成本记录只在任务
|
||
完成时写,运行中的子任务花费在循环里观测不到,创建人
|
||
配额是按月不是按分工,所以不存在可执行的分工费用门,
|
||
v5 不加 max_tokens/max_cost 字段;② 每分工轮数上限:
|
||
普通任务在 worker 侧没有按任务的轮数上限(只有 goal
|
||
任务的 goal_max_rounds 与 worker 进程级的 max_turns),
|
||
所以 max_rounds 字段在子任务执行层无法执行,v5 不加;
|
||
③ 写范围隔离:沙箱按项目 worktree 隔离、不按分工
|
||
量子目录隔离,prompt 里写“只写 docs/”是指令不是保证,
|
||
所以 v5 不加 scope 字段冒充隔离。这三项要做需先改
|
||
执行层(成本流、轮数帽、沙箱子目录),不在本轨范围。
|
||
|
||
本模块只放纯逻辑(校验、解析、汇总、建表),不碰 DB 之外的
|
||
任何东西,单测不需要 worker/server。
|
||
"""
|
||
|
||
import json
|
||
|
||
MAX_PARTS = 20
|
||
DEFAULT_TIMEOUT_SECONDS = 3600
|
||
DEFAULT_POLL_INTERVAL = 5.0
|
||
REPORT_RESULT_CHARS = 500
|
||
MAX_RESULT_CHARS = 2000
|
||
MAX_TIMEOUT_SECONDS = 86400
|
||
ON_FAILURES = ("fail", "tolerate")
|
||
EFFORTS = {"low", "medium", "high", "xhigh", "max"}
|
||
EXECUTORS = {"local", "docker", "cloud"}
|
||
|
||
|
||
def _is_int(value):
|
||
return isinstance(value, int) and not isinstance(value, bool)
|
||
|
||
|
||
def validate_parts(parts):
|
||
"""校验 coordinator_parts,返回 (normalized, error)。
|
||
|
||
normalized 每项:{name, prompt, model, effort, executor,
|
||
timeout_seconds, on_failure, result_chars},未指定的模型/
|
||
强度/执行器为 None(子任务跟随协调员任务或 worker 默认),
|
||
未指定的 timeout_seconds 为 None(只受全局超时约束),
|
||
on_failure 默认 fail,result_chars 默认 500。
|
||
"""
|
||
if not isinstance(parts, list):
|
||
return None, "coordinator_parts must be a list"
|
||
if not parts:
|
||
return None, "coordinator_parts must not be empty"
|
||
if len(parts) > MAX_PARTS:
|
||
return None, "coordinator_parts exceeds %d parts" % MAX_PARTS
|
||
seen = set()
|
||
out = []
|
||
for part in parts:
|
||
if not isinstance(part, dict):
|
||
return None, "each coordinator part must be an object"
|
||
name = part.get("name")
|
||
if not isinstance(name, str) or not name.strip():
|
||
return None, "each coordinator part needs a non-empty name"
|
||
name = name.strip()
|
||
if name in seen:
|
||
return None, "duplicate coordinator part name: %s" % name
|
||
seen.add(name)
|
||
prompt = part.get("prompt")
|
||
if not isinstance(prompt, str) or not prompt.strip():
|
||
return None, "part %s needs a non-empty prompt" % name
|
||
model = part.get("model")
|
||
if model is not None and (
|
||
not isinstance(model, str) or not model.strip()):
|
||
return None, "part %s: model must be a non-empty string" % name
|
||
effort = part.get("effort")
|
||
if effort is not None and effort not in EFFORTS:
|
||
return None, (
|
||
"part %s: effort must be low, medium, high, xhigh or max"
|
||
% name)
|
||
executor = part.get("executor")
|
||
if executor is not None and executor not in EXECUTORS:
|
||
return None, (
|
||
"part %s: executor must be local, docker or cloud" % name)
|
||
timeout = part.get("timeout_seconds")
|
||
if timeout is not None:
|
||
if not _is_int(timeout) or not 1 <= timeout <= \
|
||
MAX_TIMEOUT_SECONDS:
|
||
return None, (
|
||
"part %s: timeout_seconds must be an integer in"
|
||
" 1..%d" % (name, MAX_TIMEOUT_SECONDS))
|
||
on_failure = part.get("on_failure") or "fail"
|
||
if on_failure not in ON_FAILURES:
|
||
return None, (
|
||
"part %s: on_failure must be fail or tolerate" % name)
|
||
result_chars = part.get("result_chars")
|
||
if result_chars is None:
|
||
result_chars = REPORT_RESULT_CHARS
|
||
if not _is_int(result_chars) or not 0 <= result_chars <= \
|
||
MAX_RESULT_CHARS:
|
||
return None, (
|
||
"part %s: result_chars must be an integer in"
|
||
" 0..%d" % (name, MAX_RESULT_CHARS))
|
||
out.append({
|
||
"name": name,
|
||
"prompt": prompt.strip(),
|
||
"model": model.strip() if model else None,
|
||
"effort": effort,
|
||
"executor": executor,
|
||
"timeout_seconds": timeout,
|
||
"on_failure": on_failure,
|
||
"result_chars": result_chars,
|
||
})
|
||
return out, None
|
||
|
||
|
||
def parse_parts(raw):
|
||
"""DB 列里的 coordinator_parts → list;坏值给 [],绝不炸。
|
||
|
||
raw 可能是 JSON 文本(DB 列)、已解析的 list(内存里),或
|
||
None(普通任务)。非法分工一律按空处理,调用方据此判定
|
||
“不是协调员任务”,而不是把脏数据当分工执行。
|
||
"""
|
||
if not raw:
|
||
return []
|
||
if isinstance(raw, list):
|
||
parts, err = validate_parts(raw)
|
||
return parts if err is None else []
|
||
try:
|
||
data = json.loads(raw)
|
||
except (TypeError, ValueError):
|
||
return []
|
||
if not isinstance(data, list):
|
||
return []
|
||
parts, err = validate_parts(data)
|
||
return parts if err is None else []
|
||
|
||
|
||
def ensure_columns(conn):
|
||
"""给 tasks 表补协调员三列(与 migrate_tasks_table 同款写法)。
|
||
|
||
coordinator_parts:协调员任务的分工 JSON(普通任务为 NULL)。
|
||
parent_task_id:子任务的协调员父任务 id(非子任务为 NULL)。
|
||
coordinator_part:子任务的分工名(复用与续跑按它匹配)。
|
||
SQLite 与 Postgres 两支都走它;已存在时 no-op。
|
||
"""
|
||
try:
|
||
cols = {row[1] for row in conn.execute(
|
||
"PRAGMA table_info(tasks)").fetchall()}
|
||
except Exception:
|
||
return
|
||
for name, coltype in {
|
||
"coordinator_parts": "TEXT",
|
||
"parent_task_id": "TEXT",
|
||
"coordinator_part": "TEXT",
|
||
# 派生默认列:协调员子任务 INSERT 也写这两列,
|
||
# 老库缺列时这里补上,否则 INSERT 会炸。
|
||
"child_model": "TEXT",
|
||
"child_effort": "TEXT"}.items():
|
||
if name not in cols:
|
||
try:
|
||
conn.execute(
|
||
"ALTER TABLE tasks ADD COLUMN %s %s" % (name, coltype))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
TERMINAL_STATUSES = frozenset(
|
||
{"completed", "failed", "interrupted", "merged", "merge-failed"})
|
||
|
||
|
||
def nonterminal_children(children):
|
||
"""未终态的子任务(P1 孤儿判断用,纯函数)。
|
||
|
||
children 为 [{name, id, status, ...}](worker 的 _coord_child_rows
|
||
拼出来的 merged 形态,或 _coord_sync_children 的 [{name, id}]
|
||
补上 status 后的形态);返回其中 status 不在终态集里的项
|
||
(缺 status 按未终态算,绝不把“查不到”当成“已完成”)。
|
||
worker 的孤儿标记与取消/超时终局都以它为准。
|
||
"""
|
||
out = []
|
||
for ch in children or []:
|
||
if not isinstance(ch, dict):
|
||
continue
|
||
if ch.get("status") not in TERMINAL_STATUSES:
|
||
out.append(ch)
|
||
return out
|
||
|
||
|
||
def parts_by_name(parts):
|
||
"""分工列表 → {name: part};parts 为 None 时返回 {}。"""
|
||
out = {}
|
||
for part in parts or []:
|
||
if isinstance(part, dict) and part.get("name"):
|
||
out[part["name"]] = part
|
||
return out
|
||
|
||
|
||
def evaluate_outcome(children, parts=None):
|
||
"""合流评价(纯函数):返回 (overall, failed_names,
|
||
tolerated_names)。
|
||
|
||
completed 计完成;未完成的项中,on_failure 为 tolerate
|
||
的分工计容忍、不拖垮整体,其余计失败。未知分工名按
|
||
fail 处理(fail-closed,绝不把查不到的分工当容忍)。
|
||
空 children 时 overall 为 failed(与旧 build_report 一致,
|
||
失败名为空——调用方按“无子任务”理解,不按名理解)。
|
||
"""
|
||
by_name = parts_by_name(parts)
|
||
has_parts = parts is not None
|
||
done = 0
|
||
failed = []
|
||
tolerated = []
|
||
for ch in children or []:
|
||
name = ch.get("name")
|
||
if ch.get("status") == "completed":
|
||
done += 1
|
||
continue
|
||
part = by_name.get(name) if has_parts else None
|
||
if has_parts and part is not None and \
|
||
part.get("on_failure") == "tolerate":
|
||
tolerated.append(name)
|
||
else:
|
||
failed.append(name)
|
||
overall = "completed" if children and not failed else "failed"
|
||
return overall, failed, tolerated
|
||
|
||
|
||
def build_report(children, parts=None):
|
||
"""合流汇总:children 为 [{name, id, status, result_text,
|
||
error_text}],返回 (report_text, overall_status)。
|
||
|
||
不传 parts 时为旧行为:全部 completed 才 completed,
|
||
单项输出截断到 REPORT_RESULT_CHARS。传 parts 时按
|
||
evaluate_outcome 算总体(容忍分工的失败不拖垮整体,
|
||
头部点名已容忍项),单项截断按该分工的 result_chars。
|
||
取消与超时路径由调用方直接定 interrupted/failed,不走
|
||
这里。汇总报告本身仍受 worker 写回的 4000 字上限约束
|
||
(超长时尾部项会被截断,头部计数不受影响)。
|
||
"""
|
||
by_name = parts_by_name(parts)
|
||
lines = []
|
||
done = 0
|
||
for ch in children:
|
||
st = ch.get("status") or "?"
|
||
if st == "completed":
|
||
done += 1
|
||
part = by_name.get(ch.get("name"))
|
||
chars = part.get("result_chars") if part is not None else \
|
||
REPORT_RESULT_CHARS
|
||
lines.append("## %s (%s)" % (ch.get("name"), st))
|
||
lines.append("子任务:%s" % ch.get("id"))
|
||
res = (ch.get("result_text") or "")[:chars]
|
||
err = (ch.get("error_text") or "")[:chars]
|
||
if res:
|
||
lines.append(res)
|
||
if err:
|
||
lines.append("错误:%s" % err)
|
||
if not res and not err:
|
||
lines.append("(无输出)")
|
||
lines.append("")
|
||
if parts is None:
|
||
overall = "completed" if children and \
|
||
done == len(children) else "failed"
|
||
head = "协调员汇总:%d/%d 个子任务完成,总体 %s" % (
|
||
done, len(children), overall)
|
||
else:
|
||
overall, _failed, tolerated = evaluate_outcome(children, parts)
|
||
head = "协调员汇总:%d/%d 个子任务完成,总体 %s" % (
|
||
done, len(children), overall)
|
||
if tolerated:
|
||
head += "(%d 项失败已容忍:%s)" % (
|
||
len(tolerated), "、".join(tolerated))
|
||
return head + "\n\n" + "\n".join(lines), overall
|