Files
codex-app-server/cloud/coordinator.py
T
host c15c224570 feat: bounded subagent delegation (v5)
Coordinator parts gain three enforced bounds, validated in
validate_parts and carried through the coordinator_parts JSON
(no schema change): timeout_seconds (1..86400, per-attempt
wall clock from coordinator start; the coordinator poll loop
cancels only that child with the same cancel mechanism as
the global timeout, other parts keep waiting, audit
coordinator_child_bound_exceeded), on_failure fail|tolerate
(tolerated failures do not fail the coordinator, are named
in the report head, and their failed children are not
requeued on coordinator re-run while interrupted children
still are), and result_chars (0..2000, default 500, enforced
in build_report; the 4000-char report writeback cap still
applies). evaluate_outcome is the single outcome rule used
by both build_report and the worker error text, so tolerated
failures never leak into the coordinator failure reason.
Unknown part names fail closed.

Deliberately NOT added, after reading the execution layer:
per-part token/cost budget (cost is recorded only on
completion, so a running child's spend is unobservable and
creator quota is monthly, not per part), per-part round cap
(normal tasks have no per-task round cap in the worker),
and write-scope isolation (sandbox is per-project worktree,
a scope string in a prompt would be an instruction, not a
guarantee). Tests: test_delegation_bounds.py (8 tests).
2026-11-04 18:01:44 +00:00

314 lines
14 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 侧。
- 有界委派(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