建任务时传 coordinator_parts(分工列表,1-20 项,分工名唯一)即为协调员任务:本身不跑模型回合,worker 认领后一次建齐子任务(普通任务,并行被各 worker 认领),等全部终态后合流成汇总报告写回。子任务带 parent_task_id 与 coordinator_part,控制台可用 parent_task_id 查到;协调员重跑按分工名复用已完成子任务、失败/中断的回 queued 重跑;取消与超时尽力取消未终态子任务。子任务的配额/key/执行器门照常生效,不构成绕过。协调员任务拒绝与 goal/plan/验收命令同用。控制台:任务列表 [协调]/[协调子任务] 标记、详情分工与子任务区、新建表单子任务 JSON 输入。新单测 26 个。
161 lines
6.5 KiB
Python
161 lines
6.5 KiB
Python
"""协调员:把一个任务拆成多个并行子任务、等齐、合流汇总。
|
||
|
||
设计(与现有任务模型对齐,不另起一套执行器):
|
||
|
||
- 协调员本身是一个云端任务:建任务时传 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
|