Files
codex-app-server/cloud/coordinator.py
T
host 14757f15c4 feat: 子任务默认模型/强度(child_model/child_effort)
主任务可显式声明派生默认,或在 prompt 里写“子任务用 gpt-6-luna max”
由服务端解析填入;marker task-create 与协调员分工未显式写模型/强度时
按 块/分工 > 派生默认 > 父任务模型/强度 继承,fork 复制派生默认,
continue 可改派生默认;console 新建/继续表单与任务详情同步展示。
2026-10-29 05:27:22 +08:00

196 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""协调员:把一个任务拆成多个并行子任务、等齐、合流汇总。
设计(与现有任务模型对齐,不另起一套执行器):
- 协调员本身是一个云端任务:建任务时传 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