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:
1 parent
29efc6c61f
commit
7c46ac8e5b
6 files changed
+892
-10
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user