"""协调员:把一个任务拆成多个并行子任务、等齐、合流汇总。 设计(与现有任务模型对齐,不另起一套执行器): - 协调员本身是一个云端任务:建任务时传 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 侧。 本模块只放纯逻辑(校验、解析、汇总、建表),不碰 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", # 派生默认列:协调员子任务 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 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