"""协调员:把一个任务拆成多个并行子任务、等齐、合流汇总。 设计(与现有任务模型对齐,不另起一套执行器): - 协调员本身是一个云端任务:建任务时传 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