- Docker quota watchdog + fail-closed quota checks, system tasks need real owner - max_cost_usd actually accumulated, cancel records cost, estimated rows excluded from unpriced cap, cache_write declared non-billed - goal-resume keeps original verify on breakpoint resume, goal retry falls back to resume, goal_max_rounds capped at 100 at create and execution - coordinator children inherit parent model/effort, orphans marked interrupted with audit - PR jobs on docker workers return explicit needs_local_worker instead of silent empty - worker code signature covers 6 files and is checked before claim, fail-closed - sub2api lazy provisioning with rotation, backup whitelist + 0600, push SSRF block, repos/workers desensitized, key/project audits and limits, console buttons/quota/status wording, coordinator/automation quota reserve
192 lines
7.9 KiB
Python
192 lines
7.9 KiB
Python
"""协调员:把一个任务拆成多个并行子任务、等齐、合流汇总。
|
||
|
||
设计(与现有任务模型对齐,不另起一套执行器):
|
||
|
||
- 协调员本身是一个云端任务:建任务时传 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"}.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
|