Merge pull request #63 from openqj/goal-long-task
CI verify pass 2m10s,合并后走新链路推生产。
This commit is contained in:
3 files changed
+502
-57
No files matched your search
@@ -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 已分流处理 | 已实测(修过一轮才通) |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
Reference in new issue
Block a user