feat: 协调员任务(并行子任务、等齐、合流汇总)

建任务时传 coordinator_parts(分工列表,1-20 项,分工名唯一)即为协调员任务:本身不跑模型回合,worker 认领后一次建齐子任务(普通任务,并行被各 worker 认领),等全部终态后合流成汇总报告写回。子任务带 parent_task_id 与 coordinator_part,控制台可用 parent_task_id 查到;协调员重跑按分工名复用已完成子任务、失败/中断的回 queued 重跑;取消与超时尽力取消未终态子任务。子任务的配额/key/执行器门照常生效,不构成绕过。协调员任务拒绝与 goal/plan/验收命令同用。控制台:任务列表 [协调]/[协调子任务] 标记、详情分工与子任务区、新建表单子任务 JSON 输入。新单测 26 个。
This commit is contained in:
host committed 2026-10-27 15:45:17 +08:00
1 parent 29efc6c61f
commit 7c46ac8e5b
6 files changed
+892 -10

No files matched your search

+160
View File
@@ -0,0 +1,160 @@
"""协调员:把一个任务拆成多个并行子任务、等齐、合流汇总。
设计(与现有任务模型对齐,不另起一套执行器):
- 协调员本身是一个云端任务:建任务时传 coordinator_parts(分工
列表),它不跑模型回合;worker 认领后按分工一次建齐子任务
(普通任务,并行被各 worker 认领),等全部终态后合流成一份
汇总报告写回协调员任务。
- 子任务是普通任务:配额门、key 门、执行器过滤、取消语义全部
走现有链路,协调员不构成绕过;子任务带 parent_task_id 与
coordinator_part(分工名),控制台可用 parent_task_id 查到它们。
- 幂等与续跑:协调员任务重跑(重试/续跑)时按分工名复用已有
子任务,不重复建;已完成的子任务直接复用,失败/中断的回
queued 重跑——重试协调员即重试未完成的分工。
- 取消与超时:协调员被取消时尽力取消未终态的子任务;等齐
超时时同样取消挂起的子任务并标 failed,报告里写清哪几项
没完成。取消路径的协调员标 interrupted,不发完成/失败推送
(与普通任务的取消口径一致)。
本模块只放纯逻辑(校验、解析、汇总、建表),不碰 DB 之外的
任何东西,单测不需要 worker/server。
"""
import json
MAX_PARTS = 20
DEFAULT_TIMEOUT_SECONDS = 3600
DEFAULT_POLL_INTERVAL = 5.0
REPORT_RESULT_CHARS = 500
EFFORTS = {"low", "medium", "high", "xhigh", "max"}
EXECUTORS = {"local", "docker", "cloud"}
def validate_parts(parts):
"""校验 coordinator_parts,返回 (normalized, error)。
normalized 每项:{name, prompt, model, effort, executor},
未指定的三项为 None(子任务跟随协调员任务或 worker 默认)。
"""
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)
out.append({
"name": name,
"prompt": prompt.strip(),
"model": model.strip() if model else None,
"effort": effort,
"executor": executor,
})
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"}.items():
if name not in cols:
try:
conn.execute(
"ALTER TABLE tasks ADD COLUMN %s %s" % (name, coltype))
except Exception:
pass
def build_report(children):
"""合流汇总:children 为 [{name, id, status, result_text,
error_text}],返回 (report_text, overall_status)。
overall:全部 completed 才 completed,否则 failed(取消与超时
路径由调用方直接定 interrupted/failed,不走这里)。单项输出
截断到 REPORT_RESULT_CHARS,汇总报告本身仍受 worker 写回的
4000 字上限约束(超长时尾部项会被截断,头部计数不受影响)。
"""
lines = []
done = 0
for ch in children:
st = ch.get("status") or "?"
if st == "completed":
done += 1
lines.append("## %s (%s)" % (ch.get("name"), st))
lines.append("子任务:%s" % ch.get("id"))
res = (ch.get("result_text") or "")[:REPORT_RESULT_CHARS]
err = (ch.get("error_text") or "")[:REPORT_RESULT_CHARS]
if res:
lines.append(res)
if err:
lines.append("错误:%s" % err)
if not res and not err:
lines.append("(无输出)")
lines.append("")
overall = "completed" if children and done == len(children) else "failed"
head = "协调员汇总:%d/%d 个子任务完成,总体 %s" % (
done, len(children), overall)
return head + "\n\n" + "\n".join(lines), overall