Merge pull request #63 from openqj/goal-long-task

CI verify pass 2m10s,合并后走新链路推生产。
This commit is contained in:
openqj authored and GitHub committed 2026-10-27 02:49:13 +08:00
commit 028ef02a86
3 files changed
+502 -57

No files matched your search

+2 -2
View File
@@ -138,12 +138,12 @@ turn 可以真正并发(不是轮流排队)。
| 线程续跑 / fork / 归档 | `run`(resume + 新回合)、`fork`、`archive`、`list` | 已实测 | | 线程续跑 / fork / 归档 | `run`(resume + 新回合)、`fork`、`archive`、`list` | 已实测 |
| 宿主级子代理委派 | `delegate`:子 thread(默认 read-only)跑完,结果可 `--report-to-parent` 汇总回主 thread | 已实测 | | 宿主级子代理委派 | `delegate`:子 thread(默认 read-only)跑完,结果可 `--report-to-parent` 汇总回主 thread | 已实测 |
| 模型内子代理 | prompt 明确要求派生,见 `examples/subagent-prompt.txt`;与宿主委派是两层,别混 | 已实测(模板 + 协议) | | 模型内子代理 | prompt 明确要求派生,见 `examples/subagent-prompt.txt`;与宿主委派是两层,别混 | 已实测(模板 + 协议) |
| 长任务 / 目标循环 | `goal`:同一 thread 多回合,直到 `--verify` 验收命令通过或到 `--max-rounds` | 已实测(1 轮通过) | | 长任务 / 目标循环 | `goal`:同一 thread 多回合,直到 `--verify` 验收命令通过或到 `--max-rounds`;每轮落盘检查点,中断后 `goal-resume` 从下一轮续跑(goal-met / goal-unmet 终局后不许续跑,须新建);`--turn-timeout 0` 不限时,`--verify-timeout` 限验测;回合超时下一轮提示写明是回合中断不是验收失败 | 已实测:端到端、断点续跑、正常多轮反馈修复 |
| 自动化 / 评审队列 | `automations --add` + `run-due`:到期的任务自动派发,结果进 host-state.json 评审队列 | 已实测 | | 自动化 / 评审队列 | `automations --add` + `run-due`:到期的任务自动派发,结果进 host-state.json 评审队列 | 已实测 |
| 沙箱边界 | read-only thread 的写文件请求被拦下,文件确实没建出来 | 已实测 | | 沙箱边界 | read-only thread 的写文件请求被拦下,文件确实没建出来 | 已实测 |
| 审批控制 | untrusted + deny 时 agent 明确收到审批拒绝,没拿到命令输出 | 已实测 | | 审批控制 | untrusted + deny 时 agent 明确收到审批拒绝,没拿到命令输出 | 已实测 |
| 模型内子代理 | 3 子代理并行检查并汇总,视角是真的不同,不是重复一句话 | 已实测 | | 模型内子代理 | 3 子代理并行检查并汇总,视角是真的不同,不是重复一句话 | 已实测 |
| 中断 / 续跑控制 | `turn/interrupt` 后状态为 interrupted;`compact` 需先 resume(host.py 的 compact 命令已包好这一步) | 已实测 | | 中断 / 续跑控制 | `turn/interrupt` 后状态为 interrupted;`host.py interrupt --task` 已接协议;`compact` 需先 resume(host.py 的 compact 命令已包好这一步);超时回合也记 interrupted 且保留部分文本 | 已实测(interrupt 真 server 回 no active turn 时不改状态) |
| 合并冲突处理 | 先合的进主仓,后合同一行的冲突 abort,主仓干净,冲突 worktree 留着人工解 | 已实测 | | 合并冲突处理 | 先合的进主仓,后合同一行的冲突 abort,主仓干净,冲突 worktree 留着人工解 | 已实测 |
| 自动化幂等 | 未到期的自动化 run-due 时不重复派发 | 已实测 | | 自动化幂等 | 未到期的自动化 run-due 时不重复派发 | 已实测 |
| 宿主提供 MCP 工具 | 隔离 CODEX_HOME 注册 stdio MCP,agent 能发现并调用;注意 MCP 审批是 elicitation 协议,不是通用 decision 字段,host.py 已分流处理 | 已实测(修过一轮才通) | | 宿主提供 MCP 工具 | 隔离 CODEX_HOME 注册 stdio MCP,agent 能发现并调用;注意 MCP 审批是 elicitation 协议,不是通用 decision 字段,host.py 已分流处理 | 已实测(修过一轮才通) |
+261 -55
View File
@@ -96,6 +96,21 @@ def _sum_turn_usage(events, thread_id, turn_id):
return total return total
def _partial_text_from_events(events):
"""超时回合的 best-effort 部分文本:从事件窗口里已完成的
agentMessage 收集文本(不含流式 delta,避免重复拼接)。
找不到时返回空串,调用方不得把部分文本当成最终结论。"""
parts = []
for ev in events or []:
p = ev.get("params") or {}
item = p.get("item") or {}
if item.get("type") == "agentMessage" and item.get("text"):
text = item["text"]
if not parts or parts[-1] != text:
parts.append(text)
return "\n".join(parts)
def _add_usage(a, b): def _add_usage(a, b):
"""两份用量相加(任一为空时返回另一份;都为空返回 None)。""" """两份用量相加(任一为空时返回另一份;都为空返回 None)。"""
if not a: if not a:
@@ -457,11 +472,21 @@ class Server:
t = resp["result"].get("turn") or {} t = resp["result"].get("turn") or {}
turn_id = t.get("id") turn_id = t.get("id")
try: try:
done = q.get(timeout=timeout) # 对齐 Codex App 的长任务语义:timeout 为 None 或 0
# 表示不限时、一直等到 turn/completed(由用户中断
# 或模型自己结束);正数才按秒计时。默认仍是 600,
# 由调用方(CLI --turn-timeout / 配置 turn_timeout)
# 显式改,不改默认就不会悄悄挂起自动化。
if timeout and timeout > 0:
done = q.get(timeout=timeout)
else:
done = q.get()
except queue.Empty: except queue.Empty:
with self.events_lock: with self.events_lock:
evs = list(self.events[start_events:]) evs = list(self.events[start_events:])
return {"status": "timeout", "text": "", "turn_id": turn_id, return {"status": "timeout",
"text": _partial_text_from_events(evs),
"turn_id": turn_id,
"usage": _sum_turn_usage(evs, thread_id, turn_id)} "usage": _sum_turn_usage(evs, thread_id, turn_id)}
turn = (done.get("params") or {}).get("turn") or {} turn = (done.get("params") or {}).get("turn") or {}
text = "" text = ""
@@ -506,6 +531,18 @@ class Server:
finally: finally:
self.turn_queues.pop(thread_id, None) self.turn_queues.pop(thread_id, None)
def interrupt_turn(self, thread_id, turn_id):
"""对齐 Codex App 的停止按钮:向 app-server 发 turn/interrupt。
协议要求 threadId + turnId;turn_id 缺失时无法中断,返回 None。
注意:中断只对本 server 进程正在跑的 turn 可靠,另一进程的
turn 未必能被本进程的中断请求停下,此时应直接 Ctrl-C
等待中的那个进程(其 finally 会清队列,recover 会标 interrupted)。"""
if not thread_id or not turn_id:
return None
return self.request("turn/interrupt",
{"threadId": thread_id, "turnId": turn_id},
timeout=120)
def _turn_error_from_history(self, thread_id, turn_id): def _turn_error_from_history(self, thread_id, turn_id):
"""从 codex thread_history 库里取失败回合的 error message。 """从 codex thread_history 库里取失败回合的 error message。
@@ -1405,27 +1442,40 @@ def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
def run_task_turn(server, state, task, prompt, model, effort, def run_task_turn(server, state, task, prompt, model, effort,
sandbox=None): sandbox=None, timeout=None):
# task 自带 sandbox 时优先用它(只读评审任务不能被全局默认覆盖), # task 自带 sandbox 时优先用它(只读评审任务不能被全局默认覆盖),
# 否则用调用方显式传的 sandbox;两者都没有时不传 sandboxPolicy, # 否则用调用方显式传的 sandbox;两者都没有时不传 sandboxPolicy,
# 保留 thread 原有设置(旧任务没有 sandbox 记录时尤其重要)。 # 保留 thread 原有设置(旧任务没有 sandbox 记录时尤其重要)。
# timeout 为 None 时不传给 server(沿用 server 默认 600),
# 这样旧 FakeServer(不接受 timeout 参数)不受影响;显式
# 传了才透传,0 由 server 侧解释为不限时。
use_sandbox = task.get("sandbox") or sandbox use_sandbox = task.get("sandbox") or sandbox
started = time.time() started = time.time()
turn_kwargs = {}
if timeout is not None:
turn_kwargs["timeout"] = timeout
result = server.run_turn(task["thread_id"], prompt, model=model, result = server.run_turn(task["thread_id"], prompt, model=model,
effort=effort, sandbox=use_sandbox) effort=effort, sandbox=use_sandbox,
**turn_kwargs)
finished = time.time() finished = time.time()
record_turn_usage(state, failed=(result["status"] != "completed")) record_turn_usage(state, failed=(result["status"] != "completed"))
task["turns"].append({"prompt": prompt, "result": result, task["turns"].append({"prompt": prompt, "result": result,
"started_at": started, "finished_at": finished}) "started_at": started, "finished_at": finished})
task["last_turn_at"] = finished task["last_turn_at"] = finished
task["status"] = "completed" if result["status"] == "completed" \ # 对齐 Codex App:超时 / 被中断不是失败,是可续跑的中断态;
else "failed" # 只有真正的 failed 才记 failed,recover 才能区分两者。
if result["status"] == "completed":
task["status"] = "completed"
elif result["status"] in ("timeout", "interrupted"):
task["status"] = "interrupted"
else:
task["status"] = "failed"
task["last_text"] = result.get("text", "") task["last_text"] = result.get("text", "")
return result return result
def run_task_turn_with_retry(server, state, task, prompt, model, effort, def run_task_turn_with_retry(server, state, task, prompt, model, effort,
sandbox=None, cfg=None): sandbox=None, cfg=None, timeout=None):
"""失败自动重试:默认 max_retries=0 时行为不变(只跑一次); """失败自动重试:默认 max_retries=0 时行为不变(只跑一次);
配了重试时,失败后同 prompt 再跑,最多 max_retries 次, 配了重试时,失败后同 prompt 再跑,最多 max_retries 次,
每次重试前先查预算,超预算直接停,不烧钱。""" 每次重试前先查预算,超预算直接停,不烧钱。"""
@@ -1447,7 +1497,7 @@ def run_task_turn_with_retry(server, state, task, prompt, model, effort,
task["last_text"] = "" task["last_text"] = ""
break break
result = run_task_turn(server, state, task, prompt_try, result = run_task_turn(server, state, task, prompt_try,
model, effort, sandbox) model, effort, sandbox, timeout=timeout)
# 各次尝试的真实用量都要算(失败尝试花的钱也是钱), # 各次尝试的真实用量都要算(失败尝试花的钱也是钱),
# 最后一次的结果里带汇总,不只带最后一次。 # 最后一次的结果里带汇总,不只带最后一次。
usage = _add_usage(usage, result.get("usage")) usage = _add_usage(usage, result.get("usage"))
@@ -1458,6 +1508,129 @@ def run_task_turn_with_retry(server, state, task, prompt, model, effort,
return result return result
def run_goal_loop(server, state, state_path, task, goal_text, verify,
max_rounds, model, effort, sandbox, cfg=None,
timeout=None, verify_timeout=600, start_round=1,
last_verify="", last_turn_status=None, last_turn_text=""):
"""目标循环(长任务版,对齐 Codex App 的可续跑语义)。
每轮:turn → 验测 → 把真实结果喂回下一轮;每轮结束立即落盘
(检查点),进程被杀 / VM 回收后可用 goal-resume 从
goal_rounds_done 的下一轮续跑,而不是从头重来。
与云端 worker 的解析契约(cloud/sandbox.py):
结论行必须是 "goal met after rounds" / "goal unmet after rounds",
每轮行必须是 "round N: turn=... verify_rc=... verify_out=...",
用量行必须是 "usage input=... cached=... ..." 格式,改动时
不能动这三行;两种结局都 exit 0(main 不抛异常即 0)。
预算 stop 是第三种结局:打印 "goal stopped after rounds: ..."
且任务记 interrupted(明天预算重置后可续跑),不冒充 unmet。
goal-unmet 穷尽轮次后不允许 goal-resume(按既有规则须新建
任务),goal-resume 只接受 goal-running / interrupted / running。
"""
task["goal"] = goal_text
task["verify_command"] = verify
task["goal_max_rounds"] = int(max_rounds)
task["goal_rounds_done"] = int(start_round) - 1
task["goal_last_verify"] = last_verify
# 循环前先落盘一次:否则第一轮 turn 进行中被杀时,磁盘上的
# 任务还没有 goal/verify_command,goal-resume 会因“没有记录”
# 而拒绝——断点续跑必须在第一轮开始前就已可续跑。
task["status"] = "goal-running"
save_state(state_path, state)
ok = False
budget_stopped = False
goal_usage = None
for round_no in range(int(start_round), int(max_rounds) + 1):
if cfg is not None and budget_exceeded(state, cfg):
budget_stopped = True
print(f"goal stopped: 预算已用完(max_daily_turns),"
f"第 {round_no} 轮未开始,任务可续跑")
break
if round_no == 1 and not last_verify and not last_turn_status:
prompt = goal_text
elif last_turn_status in ("timeout", "interrupted"):
# 上轮回合根本没跑完:说清是回合中断,不是验收失败,
# 否则 agent 会对着验收输出修一个不存在的问题。
prompt = (f"上一轮回合未完成(状态:{last_turn_status}),"
f"请继续完成目标,不要重复已完成的部分。\n"
f"目标:{goal_text}\n"
f"验收命令:{verify}\n"
f"上一轮验收输出:\n{last_verify}\n"
f"上一轮部分文本:\n{last_turn_text[:1000]}")
else:
# 生产教训:只说“验收未通过” agent 不知道改什么,
# 2026-10-25 实测 prod-retry 3 轮都原地不动。
# 必须把验收命令和失败输出喂回去,才是可用的目标循环。
prompt = (f"上一轮验收未通过,请针对性修复,不要重复已完成的部分。\n"
f"目标:{goal_text}\n"
f"验收命令:{verify}\n"
f"上一轮验收输出:\n{last_verify}")
result = run_task_turn(server, state, task, prompt,
model, effort, sandbox, timeout=timeout)
goal_usage = _add_usage(goal_usage, result.get("usage"))
last_turn_status = result["status"]
last_turn_text = result.get("text", "") or ""
try:
v_rc, v_out = None, None
if verify_timeout and verify_timeout > 0:
v = subprocess.run(verify, shell=True, cwd=task["cwd"],
capture_output=True, text=True,
timeout=verify_timeout)
else:
v = subprocess.run(verify, shell=True, cwd=task["cwd"],
capture_output=True, text=True)
v_rc = v.returncode
v_out = (v.stdout + v.stderr).strip()[:1000] or \
f"exit code {v.returncode}"
except subprocess.TimeoutExpired as exc:
# 验测本身也可能很长(测试套件):超时是验测失败,
# 不是进程挂死,喂回下一轮让 agent 缩小验测范围。
v_rc = 124
out = (exc.stdout or "") if isinstance(exc.stdout, str) \
else ""
err = (exc.stderr or "") if isinstance(exc.stderr, str) \
else ""
v_out = (f"verify timeout after {verify_timeout}s: "
f"{(out + err).strip()[:900]}")
last_verify = v_out
task["goal_rounds_done"] = round_no
task["goal_last_verify"] = last_verify
task["goal_last_turn_status"] = last_turn_status
# 检查点:非终局轮次先记 goal-running,终局轮次由下面
# 统一改写;这样被杀后 state 里能看出循环没跑完。
task["status"] = "goal-running"
save_state(state_path, state)
print(f"round {round_no}: turn={result['status']} "
f"verify_rc={v_rc} verify_out={last_verify[:300]}")
if v_rc == 0:
ok = True
break
if budget_stopped:
task["status"] = "interrupted"
task["goal_stopped_reason"] = "budget exceeded: max_daily_turns"
else:
task["status"] = "goal-met" if ok else "goal-unmet"
if task["status"] == "goal-unmet":
notify_failure(state_path, cfg, task["name"],
{"status": "goal-unmet", "text": last_verify[:200]})
save_state(state_path, state)
if budget_stopped:
print("goal stopped after rounds: budget exceeded")
else:
print(f"goal {'met' if ok else 'unmet'} after rounds")
if goal_usage:
print(f"usage input={goal_usage['input_tokens']} "
f"cached={goal_usage['cached_input_tokens']} "
f"cache_write={goal_usage['cache_write_input_tokens']} "
f"output={goal_usage['output_tokens']} "
f"reasoning={goal_usage['reasoning_output_tokens']} "
f"total={goal_usage['total_tokens']} "
f"completions={goal_usage['completions']}")
return {"ok": ok, "budget_stopped": budget_stopped,
"rounds_done": task["goal_rounds_done"],
"last_verify": last_verify, "usage": goal_usage}
def _load_config_file(path): def _load_config_file(path):
if path and os.path.exists(path): if path and os.path.exists(path):
try: try:
@@ -1487,6 +1660,10 @@ def main():
ap.add_argument("--sandbox", default=None) ap.add_argument("--sandbox", default=None)
ap.add_argument("--approval-policy", default=None) ap.add_argument("--approval-policy", default=None)
ap.add_argument("--approval", default=None, choices=["auto", "deny"]) ap.add_argument("--approval", default=None, choices=["auto", "deny"])
ap.add_argument("--turn-timeout", type=int, default=None,
help="单轮等待上限(秒);0 表示不限时、对齐 Codex App "
"等 turn/completed 的语义;不传时用配置 "
"turn_timeout,未配则默认 600")
sub = ap.add_subparsers(dest="cmd", required=True) sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("init") p = sub.add_parser("init")
@@ -1533,6 +1710,7 @@ def main():
p = sub.add_parser("fork"); p.add_argument("--task", required=True); p.add_argument("--name", required=True); p.add_argument("--sandbox", default=None) p = sub.add_parser("fork"); p.add_argument("--task", required=True); p.add_argument("--name", required=True); p.add_argument("--sandbox", default=None)
p = sub.add_parser("compact"); p.add_argument("--task", required=True) p = sub.add_parser("compact"); p.add_argument("--task", required=True)
p = sub.add_parser("run"); p.add_argument("--task", required=True); p.add_argument("--prompt", required=True) p = sub.add_parser("run"); p.add_argument("--task", required=True); p.add_argument("--prompt", required=True)
p = sub.add_parser("interrupt"); p.add_argument("--task", required=True)
p = sub.add_parser("delegate") p = sub.add_parser("delegate")
p.add_argument("--parent-task", required=True); p.add_argument("--task", required=True) p.add_argument("--parent-task", required=True); p.add_argument("--task", required=True)
p.add_argument("--name", required=True); p.add_argument("--report-to-parent", action="store_true") p.add_argument("--name", required=True); p.add_argument("--report-to-parent", action="store_true")
@@ -1540,6 +1718,14 @@ def main():
p.add_argument("--name", required=True); p.add_argument("--task", required=True) p.add_argument("--name", required=True); p.add_argument("--task", required=True)
p.add_argument("--repo", default=None); p.add_argument("--verify", required=True) p.add_argument("--repo", default=None); p.add_argument("--verify", required=True)
p.add_argument("--max-rounds", type=int, default=3) p.add_argument("--max-rounds", type=int, default=3)
p.add_argument("--verify-timeout", type=int, default=600,
help="验测命令上限(秒);0 表示不限时,默认 600")
p = sub.add_parser("goal-resume")
p.add_argument("--task", required=True)
p.add_argument("--max-rounds", type=int, default=None,
help="不传时沿用原任务的 goal_max_rounds;传了则覆盖总轮次")
p.add_argument("--verify-timeout", type=int, default=600,
help="验测命令上限(秒);0 表示不限时,默认 600")
p = sub.add_parser("parallel"); p.add_argument("--jobs", required=True) p = sub.add_parser("parallel"); p.add_argument("--jobs", required=True)
p = sub.add_parser("automations"); p.add_argument("--add", action="store_true") p = sub.add_parser("automations"); p.add_argument("--add", action="store_true")
p.add_argument("--name"); p.add_argument("--task"); p.add_argument("--repo") p.add_argument("--name"); p.add_argument("--task"); p.add_argument("--repo")
@@ -1590,6 +1776,12 @@ def main():
args.sandbox = args.sandbox or cfg.get("sandbox") args.sandbox = args.sandbox or cfg.get("sandbox")
args.approval_policy = args.approval_policy or cfg.get("approval_policy") or "never" args.approval_policy = args.approval_policy or cfg.get("approval_policy") or "never"
args.approval = args.approval or cfg.get("approval") or "deny" args.approval = args.approval or cfg.get("approval") or "deny"
# turn-timeout 解析:CLI > 配置 > 默认 600;0 保持为 0(不限时),
# 不能用 or 串联,否则 0 会被当成缺失而吞掉。
if args.turn_timeout is None:
args.turn_timeout = cfg.get("turn_timeout")
if args.turn_timeout is None:
args.turn_timeout = 600
args.codex_home = args.codex_home or _resolve_path(cfg_dir, cfg.get("codex_home")) args.codex_home = args.codex_home or _resolve_path(cfg_dir, cfg.get("codex_home"))
args.codex_bin = args.codex_bin or _resolve_path(cfg_dir, cfg.get("codex_bin")) args.codex_bin = args.codex_bin or _resolve_path(cfg_dir, cfg.get("codex_bin"))
args.state = args.state or _resolve_path(cfg_dir, cfg.get("state")) or DEFAULT_STATE args.state = args.state or _resolve_path(cfg_dir, cfg.get("state")) or DEFAULT_STATE
@@ -1849,7 +2041,8 @@ def main():
save_state(args.state, state) save_state(args.state, state)
result = run_task_turn_with_retry(srv, state, task, args.task, result = run_task_turn_with_retry(srv, state, task, args.task,
args.model, args.effort, args.model, args.effort,
args.sandbox, cfg=cfg) args.sandbox, cfg=cfg,
timeout=args.turn_timeout)
if result["status"] != "completed": if result["status"] != "completed":
notify_failure(args.state, cfg, args.name, result) notify_failure(args.state, cfg, args.name, result)
save_state(args.state, state) save_state(args.state, state)
@@ -1910,7 +2103,8 @@ def main():
return return
srv.resume_thread(t["thread_id"]) srv.resume_thread(t["thread_id"])
result = run_task_turn(srv, state, t, build_fix_prompt(t), result = run_task_turn(srv, state, t, build_fix_prompt(t),
args.model, args.effort, args.sandbox) args.model, args.effort, args.sandbox,
timeout=args.turn_timeout)
if result["status"] == "completed": if result["status"] == "completed":
for c in oc: for c in oc:
c["status"] = "resolved" c["status"] = "resolved"
@@ -1994,11 +2188,30 @@ def main():
srv.resume_thread(t["thread_id"]) srv.resume_thread(t["thread_id"])
result = run_task_turn_with_retry(srv, state, t, args.prompt, result = run_task_turn_with_retry(srv, state, t, args.prompt,
args.model, args.effort, args.model, args.effort,
args.sandbox, cfg=cfg) args.sandbox, cfg=cfg,
timeout=args.turn_timeout)
if result["status"] != "completed": if result["status"] != "completed":
notify_failure(args.state, cfg, args.task, result) notify_failure(args.state, cfg, args.task, result)
save_state(args.state, state) save_state(args.state, state)
print(json.dumps(result, indent=2, ensure_ascii=False)) print(json.dumps(result, indent=2, ensure_ascii=False))
elif args.cmd == "interrupt":
t = state["tasks"][args.task]
turn_id = None
for turn in reversed(t.get("turns", [])):
turn_id = (turn.get("result") or {}).get("turn_id")
if turn_id:
break
if not turn_id:
print(f"interrupt {args.task}: 没有可中断的 turn_id")
else:
srv.resume_thread(t["thread_id"])
resp = srv.interrupt_turn(t["thread_id"], turn_id)
# 只有服务端接受中断才改状态;它说“没有活动回合”
# 时说明该轮已结束,硬标 interrupted 会掩盖真实终态。
if resp and "error" not in resp:
t["status"] = "interrupted"
save_state(args.state, state)
print(f"interrupt {args.task}: turn={turn_id} resp={resp}")
elif args.cmd == "delegate": elif args.cmd == "delegate":
parent = state["tasks"][args.parent_task] parent = state["tasks"][args.parent_task]
# 子 thread:cwd 与父任务相同 worktree 时只读更安全, # 子 thread:cwd 与父任务相同 worktree 时只读更安全,
@@ -2014,7 +2227,8 @@ def main():
state["tasks"][args.name] = sub state["tasks"][args.name] = sub
save_state(args.state, state) save_state(args.state, state)
result = run_task_turn(srv, state, sub, args.task, result = run_task_turn(srv, state, sub, args.task,
args.model, args.effort, args.sandbox) args.model, args.effort, args.sandbox,
timeout=args.turn_timeout)
save_state(args.state, state) save_state(args.state, state)
print(json.dumps({"sub_task": args.name, "status": sub["status"], print(json.dumps({"sub_task": args.name, "status": sub["status"],
"text": result.get("text", "")}, "text": result.get("text", "")},
@@ -2024,7 +2238,8 @@ def main():
rep = run_task_turn(srv, state, parent, rep = run_task_turn(srv, state, parent,
f"子任务 {args.name} 的结论如下,请汇总到主任务结论里," f"子任务 {args.name} 的结论如下,请汇总到主任务结论里,"
f"不要重复做子任务的工作:\n{result.get('text', '')}", f"不要重复做子任务的工作:\n{result.get('text', '')}",
args.model, args.effort, args.sandbox) args.model, args.effort, args.sandbox,
timeout=args.turn_timeout)
save_state(args.state, state) save_state(args.state, state)
print(f"reported to parent: {rep.get('text', '')[:1000]}") print(f"reported to parent: {rep.get('text', '')[:1000]}")
elif args.cmd == "goal": elif args.cmd == "goal":
@@ -2032,46 +2247,35 @@ def main():
args.model, args.effort, args.sandbox, args.model, args.effort, args.sandbox,
args.approval_policy) args.approval_policy)
save_state(args.state, state) save_state(args.state, state)
ok = False run_goal_loop(srv, state, args.state, task, args.task,
last_verify = "" args.verify, args.max_rounds, args.model,
goal_usage = None args.effort, args.sandbox, cfg=cfg,
for round_no in range(1, args.max_rounds + 1): timeout=args.turn_timeout,
if round_no == 1: verify_timeout=args.verify_timeout)
prompt = args.task elif args.cmd == "goal-resume":
else: t = state["tasks"][args.task]
# 生产教训:只说“验收未通过” agent 不知道改什么, if t.get("status") not in ("goal-running", "interrupted",
# 2026-10-25 实测 prod-retry 3 轮都原地不动。 "running"):
# 必须把验收命令和失败输出喂回去,才是可用的目标循环。 print(f"goal-resume 拒绝:{args.task} 当前状态是 "
prompt = (f"上一轮验收未通过,请针对性修复,不要重复已完成的部分。\n" f"{t.get('status')},已终局或已达标;"
f"目标:{args.task}\n" f"goal-unmet / goal-met 后请新建任务,不要同任务续跑")
f"验收命令:{args.verify}\n" return
f"上一轮验收输出:\n{last_verify}") if not t.get("goal") or not t.get("verify_command"):
result = run_task_turn(srv, state, task, prompt, print(f"goal-resume 拒绝:{args.task} 没有 goal/verify_command 记录")
args.model, args.effort, args.sandbox) return
goal_usage = _add_usage(goal_usage, result.get("usage")) max_rounds = args.max_rounds or t.get("goal_max_rounds") or 3
v = subprocess.run(args.verify, shell=True, cwd=task["cwd"], srv.resume_thread(t["thread_id"])
capture_output=True, text=True) last_turn = (t.get("turns") or [{}])[-1]
last_verify = (v.stdout + v.stderr).strip()[:1000] or \ last_result = last_turn.get("result") or {}
f"exit code {v.returncode}" run_goal_loop(srv, state, args.state, t, t["goal"],
print(f"round {round_no}: turn={result['status']} " t["verify_command"], max_rounds,
f"verify_rc={v.returncode} verify_out={last_verify[:300]}") args.model, args.effort, args.sandbox, cfg=cfg,
if v.returncode == 0: timeout=args.turn_timeout,
ok = True verify_timeout=args.verify_timeout,
break start_round=int(t.get("goal_rounds_done") or 0) + 1,
task["status"] = "goal-met" if ok else "goal-unmet" last_verify=t.get("goal_last_verify") or "",
if not ok: last_turn_status=t.get("goal_last_turn_status"),
notify_failure(args.state, cfg, args.name, last_turn_text=last_result.get("text", "") or "")
{"status": "goal-unmet", "text": last_verify[:200]})
save_state(args.state, state)
print(f"goal {'met' if ok else 'unmet'} after rounds")
if goal_usage:
print(f"usage input={goal_usage['input_tokens']} "
f"cached={goal_usage['cached_input_tokens']} "
f"cache_write={goal_usage['cache_write_input_tokens']} "
f"output={goal_usage['output_tokens']} "
f"reasoning={goal_usage['reasoning_output_tokens']} "
f"total={goal_usage['total_tokens']} "
f"completions={goal_usage['completions']}")
elif args.cmd == "parallel": elif args.cmd == "parallel":
jobs = json.load(open(args.jobs)) jobs = json.load(open(args.jobs))
# 依赖 + 优先级:先拓扑排序,缺依赖/成环直接报错不跑; # 依赖 + 优先级:先拓扑排序,缺依赖/成环直接报错不跑;
@@ -2156,7 +2360,8 @@ def main():
job, t = job_task job, t = job_task
return job["name"], run_task_turn_with_retry( return job["name"], run_task_turn_with_retry(
srv, state, t, job["task"], args.model, srv, state, t, job["task"], args.model,
args.effort, args.sandbox, cfg=cfg) args.effort, args.sandbox, cfg=cfg,
timeout=args.turn_timeout)
with ThreadPoolExecutor(max_workers=len(ready)) as ex: with ThreadPoolExecutor(max_workers=len(ready)) as ex:
for name, result in ex.map( for name, result in ex.map(
_run, [tasks[j["name"]] for j in ready]): _run, [tasks[j["name"]] for j in ready]):
@@ -2191,7 +2396,8 @@ def main():
args.approval_policy) args.approval_policy)
save_state(args.state, state) save_state(args.state, state)
result = run_task_turn(srv, state, task, auto["task"], result = run_task_turn(srv, state, task, auto["task"],
args.model, args.effort, args.sandbox) args.model, args.effort, args.sandbox,
timeout=args.turn_timeout)
auto["last_run"] = now auto["last_run"] = now
if result["status"] != "completed": if result["status"] != "completed":
notify_failure(args.state, cfg, name, result) notify_failure(args.state, cfg, name, result)
+239
View File
@@ -651,6 +651,245 @@ class TurnUsageSumTest(unittest.TestCase):
self.assertEqual(result["usage"]["completions"], 2) self.assertEqual(result["usage"]["completions"], 2)
class LongTurnAlignmentTest(unittest.TestCase):
def test_partial_text_collects_agent_messages_only(self):
events = [
{"method": "item/completed",
"params": {"item": {"type": "agentMessage",
"text": "first part"}}},
{"method": "item/agentMessage/delta",
"params": {"delta": "should be ignored"}},
{"method": "item/completed",
"params": {"item": {"type": "toolCall",
"text": "not a message"}}},
{"method": "item/completed",
"params": {"item": {"type": "agentMessage",
"text": "second part"}}},
]
self.assertEqual(host._partial_text_from_events(events),
"first part\nsecond part")
self.assertEqual(host._partial_text_from_events([]), "")
def test_timeout_maps_to_interrupted_and_threads_timeout(self):
class FakeServer:
def __init__(self):
self.seen_timeout = None
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
self.seen_timeout = timeout
return {"status": "timeout", "text": "partial",
"turn_id": "u1"}
task = {"name": "t", "thread_id": "x", "sandbox": None,
"max_retries": 0, "retry_count": 0, "turns": []}
state = {"tasks": {"t": task}, "automations": []}
srv = FakeServer()
result = host.run_task_turn(srv, state, task, "do it", "m", "low",
timeout=0)
self.assertEqual(result["status"], "timeout")
self.assertEqual(task["status"], "interrupted")
self.assertEqual(task["last_text"], "partial")
self.assertEqual(srv.seen_timeout, 0)
def test_timeout_none_keeps_old_fake_server_working(self):
class OldFakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None):
return {"status": "completed", "text": "ok"}
task = {"name": "t", "thread_id": "x", "sandbox": None,
"max_retries": 0, "retry_count": 0, "turns": []}
state = {"tasks": {"t": task}, "automations": []}
result = host.run_task_turn(OldFakeServer(), state, task, "do it",
"m", "low")
self.assertEqual(result["status"], "completed")
self.assertEqual(task["status"], "completed")
def test_interrupt_turn_sends_thread_and_turn_ids(self):
srv = object.__new__(host.Server)
seen = {}
def fake_request(method, params=None, timeout=120):
seen["method"] = method
seen["params"] = params
seen["timeout"] = timeout
return {"result": {}}
srv.request = fake_request
self.assertEqual(srv.interrupt_turn("th1", "tu1"),
{"result": {}})
self.assertEqual(seen["method"], "turn/interrupt")
self.assertEqual(seen["params"],
{"threadId": "th1", "turnId": "tu1"})
self.assertIsNone(srv.interrupt_turn("th1", None))
self.assertIsNone(srv.interrupt_turn(None, "tu1"))
class GoalLoopLongTaskTest(unittest.TestCase):
def _task(self, tmp):
return {"name": "g", "thread_id": "x", "cwd": tmp, "sandbox": None,
"max_retries": 0, "retry_count": 0, "turns": []}
def test_goal_met_on_first_round_checkpoints(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
return {"status": "completed", "text": "done",
"turn_id": "u1"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
result = host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "true", 3, "m",
"low", None, cfg={}, timeout=600)
self.assertTrue(result["ok"])
self.assertEqual(task["status"], "goal-met")
self.assertEqual(task["goal_rounds_done"], 1)
self.assertEqual(task["goal"], "make it")
self.assertEqual(task["verify_command"], "true")
saved = host.load_state(state_path)
self.assertEqual(saved["tasks"]["g"]["status"], "goal-met")
def test_goal_unmet_after_max_rounds_and_timeout_feedback(self):
class FakeServer:
def __init__(self):
self.prompts = []
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
self.prompts.append(prompt)
if len(self.prompts) == 1:
return {"status": "timeout", "text": "partial work",
"turn_id": "u1"}
return {"status": "completed", "text": "done",
"turn_id": "u2"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
srv = FakeServer()
result = host.run_goal_loop(srv, state, state_path,
task, "make it", "false", 2, "m",
"low", None, cfg={}, timeout=600)
self.assertFalse(result["ok"])
self.assertEqual(task["status"], "goal-unmet")
self.assertEqual(task["goal_rounds_done"], 2)
# 第二轮的提示必须说清是回合中断,不是验收失败。
self.assertIn("timeout", srv.prompts[1])
self.assertIn("未完成", srv.prompts[1])
saved = host.load_state(state_path)
self.assertEqual(saved["tasks"]["g"]["goal_rounds_done"], 2)
def test_goal_resume_continues_from_checkpoint(self):
class FakeServer:
def __init__(self):
self.prompts = []
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
self.prompts.append(prompt)
return {"status": "completed", "text": "done",
"turn_id": "u9"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
srv = FakeServer()
# 模拟被杀在检查点后的任务:已跑 1 轮、验测失败、状态 goal-running。
task["status"] = "goal-running"
task["goal"] = "make it"
task["verify_command"] = "true"
task["goal_max_rounds"] = 3
task["goal_rounds_done"] = 1
task["goal_last_verify"] = "missing file"
task["goal_last_turn_status"] = "completed"
task["turns"] = [{"prompt": "make it",
"result": {"status": "completed",
"text": "partial"}}]
result = host.run_goal_loop(srv, state, state_path, task,
"make it", "true", 3, "m", "low",
None, cfg={}, timeout=600,
start_round=2,
last_verify="missing file",
last_turn_status="completed")
self.assertTrue(result["ok"])
self.assertEqual(task["status"], "goal-met")
self.assertEqual(task["goal_rounds_done"], 2)
self.assertIn("missing file", srv.prompts[-1])
def test_goal_fields_saved_before_first_turn(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
raise RuntimeError("killed during turn")
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
with self.assertRaises(RuntimeError):
host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "true", 3, "m", "low",
None, cfg={}, timeout=600)
saved = host.load_state(state_path)
saved_task = saved["tasks"]["g"]
self.assertEqual(saved_task["goal"], "make it")
self.assertEqual(saved_task["verify_command"], "true")
self.assertEqual(saved_task["goal_rounds_done"], 0)
self.assertEqual(saved_task["status"], "goal-running")
def test_goal_budget_stop_is_interrupted_not_unmet(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
raise AssertionError("budget stop must not run a turn")
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
today = __import__("time").strftime("%Y-%m-%d")
state["usage"] = {"date": today, "turns": 5, "failed_turns": 0}
result = host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "true", 3, "m",
"low", None,
cfg={"max_daily_turns": 5},
timeout=600)
self.assertTrue(result["budget_stopped"])
self.assertFalse(result["ok"])
self.assertEqual(task["status"], "interrupted")
def test_goal_verify_timeout_is_a_verify_failure(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
return {"status": "completed", "text": "done",
"turn_id": "u1"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
result = host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "sleep 5", 1, "m",
"low", None, cfg={}, timeout=600,
verify_timeout=1)
self.assertFalse(result["ok"])
self.assertEqual(task["status"], "goal-unmet")
self.assertIn("verify timeout", task["goal_last_verify"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()