diff --git a/README.md b/README.md index c5f2338..200b2f6 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ turn 可以真正并发(不是轮流排队)。 | 线程续跑 / fork / 归档 | `run`(resume + 新回合)、`fork`、`archive`、`list` | 已实测 | | 宿主级子代理委派 | `delegate`:子 thread(默认 read-only)跑完,结果可 `--report-to-parent` 汇总回主 thread | 已实测 | | 模型内子代理 | prompt 明确要求派生,见 `examples/subagent-prompt.txt`;与宿主委派是两层,别混 | 已实测(模板 + 协议) | -| 长任务 / 目标循环 | `goal`:同一 thread 多回合,直到 `--verify` 验收命令通过或到 `--max-rounds`;每轮落盘检查点,中断后 `goal-resume` 从下一轮续跑(goal-met / goal-unmet 终局后不许续跑,须新建);`--turn-timeout 0` 不限时,`--verify-timeout` 限验测;回合超时下一轮提示写明是回合中断不是验收失败 | 已实测:端到端、断点续跑、正常多轮反馈修复 | +| 长任务 / 目标循环 | `goal`:同一 thread 多回合,每轮落盘检查点,中断后 `goal-resume` 从下一轮续跑(goal-met / goal-unmet 终局后不许续跑,须新建);`--turn-timeout 0` 不限时;回合超时下一轮提示写明是回合中断不是验收失败。两种验收形态:旧形态 `--verify` 验收命令通过即达标,到 `--max-rounds` 则 unmet;新形态(console 打勾 goal 模式,`goal_mode=1`)不设验收命令、不设轮次上限(`--max-rounds 0`),prompt 本身就是目标,模型在回合末尾单独一行输出“目标已达成”即宣告达标,否则一直运行,停靠取消或预算(`max_daily_turns` 耗尽时记 interrupted、可续跑) | 旧形态已实测:端到端、断点续跑、正常多轮反馈修复;新形态单测已过,真模型宣告达标尚未实弹 | | 自动化 / 评审队列 | `automations --add` + `run-due`:到期的任务自动派发,结果进 host-state.json 评审队列 | 已实测 | | 沙箱边界 | read-only thread 的写文件请求被拦下,文件确实没建出来 | 已实测 | | 审批控制 | untrusted + deny 时 agent 明确收到审批拒绝,没拿到命令输出 | 已实测 | diff --git a/cloud/console.html b/cloud/console.html index a155111..07af8e3 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -151,11 +151,7 @@ - +
@@ -316,7 +312,7 @@ async function loadTasks() { var pr = t.pr_url ? "#" + esc(t.pr_number) + "" : ""; return "" + "" + esc(shortId(t.id)) + "" + - "" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "" + + "" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + ((t.goal_mode || t.verify_command) ? "[目标] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "" + "" + esc(t.status) + "" + "" + esc(t.executor || "") + "" + "" + esc(t.worktree_host || "") + "" + @@ -442,6 +438,7 @@ async function showDetail(taskId) { " | 权限 " + esc(t.approval || "默认") + (t.plan ? " | 计划模式(只读)" : "") + (t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") + + (t.goal_mode ? " | goal 模式(不限轮次,模型宣告完成)" : (t.verify_command ? " | goal 模式(旧语义,验收命令)" : "")) + (t.pr_url ? " | PR #" + esc(t.pr_number) + "" : "") + "
prompt:
" + esc(t.prompt || ""); var body = ""; @@ -519,7 +516,7 @@ $("#refreshProjects").onclick = loadProjects; $("#refreshWorkers").onclick = loadWorkers; $("#refreshTasks").onclick = loadTasks; $("#projectSelect").onchange = loadTasks; -$("#goalMode").onchange = function () { $("#goalFields").style.display = this.checked ? "block" : "none"; }; +$("#goalMode").onchange = function () { $("#goalHint").style.display = this.checked ? "block" : "none"; }; $("#createProject").onclick = async function () { var name = $("#newProjectName").value.trim(); if (!name) { $("#projectMsg").textContent = "项目名必填"; return; } @@ -556,9 +553,9 @@ $("#createTask").onclick = async function () { body.plan = 1; } if ($("#goalMode").checked) { - body.goal = $("#goal").value.trim() || prompt; - body.verify_command = $("#verifyCommand").value.trim(); - body.goal_max_rounds = parseInt($("#goalRounds").value, 10) || 3; + // 新 goal 语义:prompt 本身就是目标;不发专用 goal 文本、 + // 验收命令与轮次(服务端 goal_mode=1 时一律不限轮次)。 + body.goal_mode = 1; } try { var t = await api("/tasks", "POST", body); diff --git a/cloud/db.py b/cloud/db.py index c2b57d6..952bce8 100644 --- a/cloud/db.py +++ b/cloud/db.py @@ -831,6 +831,7 @@ POSTGRES_SCHEMA = ( "goal TEXT, " "verify_command TEXT, " "goal_max_rounds INTEGER, " + "goal_mode INTEGER, " "model TEXT, " "effort TEXT, " "approval TEXT, " @@ -929,7 +930,8 @@ def init_postgres_schema(conn): "PRAGMA table_info(tasks)").fetchall()} for _name, _type in (("executor", "TEXT"), ("pr_url", "TEXT"), ("pr_number", "INTEGER"), - ("worktree_host", "TEXT")): + ("worktree_host", "TEXT"), + ("goal_mode", "INTEGER")): if _name not in tcols: conn.execute( "ALTER TABLE tasks ADD COLUMN %s %s" % (_name, _type)) diff --git a/cloud/sandbox.py b/cloud/sandbox.py index ac2317d..ce209f0 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -346,28 +346,33 @@ class LocalExecutor(Executor): """调 host.py goal:目标循环(turn→验测→喂回重试)。 命令:host.py [--config CFG] [--model M] goal --name - --task --verify <验收命令> + --task [--verify <验收命令>] [--max-rounds N] [--repo ]。goal 文本缺省用 prompt; - 轮次缺省 3,与 host.py 默认一致;repo 取任务的 repo 字段, - 没有则取构造时传的 repo。 + 新形态(goal_mode 任务)没有验收命令:--verify 不传, + --max-rounds 传 0(不限轮次,模型宣告完成才结束), + 0 是合法值不能用 or 兜底成 3;旧形态仍按原样传验收命令 + 与轮次。轮次缺省 3(任务行没写时),与 host.py 默认一致; + repo 取任务的 repo 字段,没有则取构造时传的 repo。 结论解析见 _goal_result_from_runner:host.py goal 两种结局 都 exit 0,不能沿用 _result_from_runner。 """ if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") verify = task.get("verify_command") - if not verify: - raise ValueError("goal 任务缺少 verify_command") goal_text = task.get("goal") or task.get("prompt") if not goal_text: raise ValueError("任务缺少 prompt") - rounds = task.get("goal_max_rounds") or 3 + rounds = task.get("goal_max_rounds") + if rounds is None: + rounds = 3 cmd = self._base_command(model=task.get("model"), effort=task.get("effort"), approval=task.get("approval")) cmd += ["goal", "--name", self._host_name(task), - "--task", goal_text, "--verify", verify, - "--max-rounds", str(rounds)] + "--task", goal_text] + if verify: + cmd += ["--verify", verify] + cmd += ["--max-rounds", str(rounds)] repo = task.get("repo") or self.repo if repo: cmd += ["--repo", repo] @@ -920,16 +925,21 @@ class DockerExecutor(Executor): if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") verify = task.get("verify_command") - if not verify: - raise ValueError("goal 任务缺少 verify_command") goal_text = task.get("goal") or task.get("prompt") if not goal_text: raise ValueError("任务缺少 prompt") - rounds = task.get("goal_max_rounds") or 3 + # 新形态(goal_mode 任务)没有验收命令:--verify 不传, + # --max-rounds 传 0(不限轮次);0 是合法值不能用 or 兜底。 + rounds = task.get("goal_max_rounds") + if rounds is None: + rounds = 3 + inner_args = ["goal", "--name", self._host_name(task), + "--task", goal_text] + if verify: + inner_args += ["--verify", verify] + inner_args += ["--max-rounds", str(rounds)] inner = self._inner_host_command( - "goal", "--name", self._host_name(task), - "--task", goal_text, "--verify", verify, - "--max-rounds", str(rounds), model=task.get("model"), + *inner_args, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval")) repo = task.get("repo") if repo: diff --git a/cloud/server.py b/cloud/server.py index 99e0c7d..e8ed940 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -416,7 +416,9 @@ def migrate_tasks_table(conn): 新列:result_text、error_text、actual_model、started_at、 finished_at、child_pid、executor、pr_url、pr_number,以及 - goal、verify_command、goal_max_rounds(goal 模式用)。 + goal、verify_command、goal_max_rounds(goal 模式用)、 + goal_mode(新 goal 语义:1 时 prompt 即目标,无验收命令、 + 不限轮次;老行 NULL 按 0 计)。 PRAGMA table_info 查现有列,缺哪个补哪个;已迁移的库再跑 一次是 no-op。worker 写执行结果、server 读任务时都依赖 这些列存在。 @@ -436,6 +438,7 @@ def migrate_tasks_table(conn): "goal": "TEXT", "verify_command": "TEXT", "goal_max_rounds": "INTEGER", + "goal_mode": "INTEGER", # HA 阶段 1:认领任务的 worker 所在主机名;/pr 据此判断 # worktree 在本机(同步跑)还是别机(排 pr 任务给该主机)。 "worktree_host": "TEXT", @@ -620,6 +623,7 @@ def _task_to_dict(row): "goal": _row_get(row, "goal"), "verify_command": _row_get(row, "verify_command"), "goal_max_rounds": _row_get(row, "goal_max_rounds"), + "goal_mode": _row_get(row, "goal_mode") or 0, "worktree_host": _row_get(row, "worktree_host"), "model": _row_get(row, "model"), "effort": _row_get(row, "effort"), @@ -1540,35 +1544,53 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): self._send_json(400, { "error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"}) return - # goal 字段:verify_command 非空即 goal 模式(worker 走 - # host.py goal 循环);goal 文本缺省用 prompt;轮次缺省 3 - # (与 host.py 默认一致),非法值 400 且不建任务。 - goal = body.get("goal") - if goal is not None and not isinstance(goal, str): - self._send_json(400, {"error": "goal must be a string"}) - return - verify_command = body.get("verify_command") - if verify_command is not None and not isinstance( - verify_command, str): - self._send_json(400, { - "error": "verify_command must be a string"}) - return - # 计划模式与 goal 模式也冲突:goal 循环就是要改文件 - # 直到验测通过,只读沙箱下它永远做不成。 - if plan and verify_command: - self._send_json(400, { - "error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"}) - return - try: - goal_max_rounds = int(body.get("goal_max_rounds", 3)) - except (TypeError, ValueError): - self._send_json(400, { - "error": "goal_max_rounds must be an integer"}) - return - if goal_max_rounds < 1: - self._send_json(400, { - "error": "goal_max_rounds must be >= 1"}) - return + # goal 模式分新旧两种语义(2026-10-27 起 console 只发新语义): + # 新语义 goal_mode=1:prompt 本身就是目标,不需要专用 goal + # 文本、不需要验收命令、不设轮次上限(goal_max_rounds 存 0, + # worker 走 host.py goal 循环的模型自决分支);传了 + # verify_command 是语义冲突,400。旧语义(verify_command + # 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值 + # 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。 + goal_mode = 1 if body.get("goal_mode") in (True, 1) else 0 + if goal_mode: + if plan: + self._send_json(400, { + "error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"}) + return + if body.get("verify_command"): + self._send_json(400, { + "error": "goal 模式不需要验收命令(达标由模型宣告)"}) + return + goal = prompt + verify_command = None + goal_max_rounds = 0 + else: + goal = body.get("goal") + if goal is not None and not isinstance(goal, str): + self._send_json(400, {"error": "goal must be a string"}) + return + verify_command = body.get("verify_command") + if verify_command is not None and not isinstance( + verify_command, str): + self._send_json(400, { + "error": "verify_command must be a string"}) + return + # 计划模式与 goal 模式也冲突:goal 循环就是要改文件 + # 直到验测通过,只读沙箱下它永远做不成。 + if plan and verify_command: + self._send_json(400, { + "error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"}) + return + try: + goal_max_rounds = int(body.get("goal_max_rounds", 3)) + except (TypeError, ValueError): + self._send_json(400, { + "error": "goal_max_rounds must be an integer"}) + return + if goal_max_rounds < 1: + self._send_json(400, { + "error": "goal_max_rounds must be >= 1"}) + return idempotency_key = body.get("idempotency_key") if idempotency_key is not None and not isinstance(idempotency_key, str): self._send_json(400, {"error": "idempotency_key must be a string"}) @@ -1596,13 +1618,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): " idempotency_key, depends_on, priority, created_by," " created_at, updated_at, last_heartbeat, executor," " goal, verify_command, goal_max_rounds," - " model, effort, approval, plan)" + " model, effort, approval, plan, goal_mode)" " VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?," - " ?, ?, ?, ?, ?, ?, ?)", + " ?, ?, ?, ?, ?, ?, ?, ?)", (task_id, project_id, prompt, idempotency_key, json.dumps(depends_on), priority, user_id, now, now, executor, goal, verify_command, goal_max_rounds, - model, effort, approval, plan), + model, effort, approval, plan, goal_mode), ) conn.commit() except (sqlite3.IntegrityError, _PgIntegrityError): diff --git a/cloud/tests/test_goal_mode.py b/cloud/tests/test_goal_mode.py new file mode 100644 index 0000000..7671203 --- /dev/null +++ b/cloud/tests/test_goal_mode.py @@ -0,0 +1,197 @@ +"""新 goal 语义单测:prompt 即目标,无验收命令,不限轮次, +模型在回合末尾单独一行输出“目标已达成”即达标。 + +不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时 +SQLite;worker 侧注入假 runner;旧语义(verify_command)任务 +仍按原样建任务、走 goal 循环。 +""" + +import http.client +import json +import os +import sqlite3 +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from types import SimpleNamespace + +try: + from cloud.auth import AuthStore + from cloud.sandbox import LocalExecutor + from cloud.server import create_server, init_db + from cloud.worker import Worker +except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.auth import AuthStore + from cloud.sandbox import LocalExecutor + from cloud.server import create_server, init_db + from cloud.worker import Worker + + +def _goal_met_runner(cmd): + return SimpleNamespace( + returncode=0, + stdout="round 1: turn=completed verify_rc=- verify_out=\n" + "goal met after rounds\n", + stderr="") + + +class ServerGoalModeTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + self.auth = AuthStore(self.db_path) + self.owner_id, self.owner_key = self.auth.create_user("owner") + self.server = create_server(self.db_path, "127.0.0.1", 0) + self.port = self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, daemon=True) + self.thread.start() + status, self.project = self._req( + "POST", "/projects", {"name": "demo"}, self.owner_key) + self.assertEqual(status, 201) + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + try: + self.auth.conn.close() + except Exception: + pass + self.tmp.cleanup() + + def _req(self, method, path, body=None, key=None): + conn = http.client.HTTPConnection( + "127.0.0.1", self.port, timeout=10) + hdrs = {} + payload = None + if key: + hdrs["X-API-Key"] = key + if body is not None: + payload = json.dumps(body) + hdrs["Content-Type"] = "application/json" + conn.request(method, path, body=payload, headers=hdrs) + resp = conn.getresponse() + raw = resp.read() + conn.close() + try: + return resp.status, json.loads(raw.decode("utf-8")) + except ValueError: + return resp.status, raw.decode("utf-8", errors="replace") + + def _create(self, **extra): + body = {"project_id": self.project["id"], "prompt": "build it"} + body.update(extra) + return self._req("POST", "/tasks", body, self.owner_key) + + def test_create_goal_mode_task(self): + status, task = self._create(goal_mode=1) + self.assertEqual(status, 201) + self.assertEqual(task["goal_mode"], 1) + # 新语义:prompt 本身就是目标,不存验收命令,轮次存 0。 + self.assertEqual(task["goal"], "build it") + self.assertIsNone(task["verify_command"]) + self.assertEqual(task["goal_max_rounds"], 0) + + def test_goal_mode_with_verify_command_400(self): + status, body = self._create(goal_mode=1, verify_command="true") + self.assertEqual(status, 400) + + def test_goal_mode_with_plan_400(self): + status, body = self._create(goal_mode=1, plan=1) + self.assertEqual(status, 400) + + def test_old_style_verify_task_still_works(self): + status, task = self._create(verify_command="true", + goal_max_rounds=2) + self.assertEqual(status, 201) + self.assertEqual(task["goal_mode"], 0) + self.assertEqual(task["verify_command"], "true") + self.assertEqual(task["goal_max_rounds"], 2) + + def test_console_goal_hint_and_no_old_inputs(self): + # console 壳是静态页:无 key 也能取,_req 对非 JSON 直接回文本。 + _status, text = self._req("GET", "/console") + self.assertIn('id="goalHint"', text) + self.assertIn("body.goal_mode = 1", text) + # 旧的专用 prompt / 验收命令 / 轮次输入已移除。 + self.assertNotIn('id="verifyCommand"', text) + self.assertNotIn('id="goalRounds"', text) + self.assertNotIn('id="goalFields"', text) + + +class ExecutorGoalModeTestCase(unittest.TestCase): + def test_local_goal_task_no_verify_zero_rounds(self): + seen = [] + + def fake(cmd): + seen.append(list(cmd)) + return _goal_met_runner(cmd) + + result = LocalExecutor("/fake/host.py", runner=fake).goal_task( + {"id": "t1", "prompt": "build it", "goal": "build it", + "goal_mode": 1, "goal_max_rounds": 0}) + self.assertEqual(result["status"], "completed") + self.assertTrue(result["goal_met"]) + # 新形态:不传 --verify,--max-rounds 原样传 0(不能被 + # or 兜底成 3,否则不限轮次悄悄变回 3 轮上限)。 + self.assertNotIn("--verify", seen[0]) + self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "0") + + +class WorkerGoalModeTestCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.db_path = os.path.join(self.tmp.name, "control.db") + init_db(self.db_path) + self.env = {"XCM_API_KEY": "test-placeholder"} + + def tearDown(self): + self.tmp.cleanup() + + def _insert(self, task_id, **extra): + now = time.time() + cols = ["id", "project_id", "prompt", "status", + "idempotency_key", "depends_on", "priority", + "created_by", "created_at", "updated_at", + "last_heartbeat"] + vals = [task_id, "p1", "do work", "queued", None, "[]", 0, + "tester", now, now, None] + for k, v in extra.items(): + cols.append(k) + vals.append(v) + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + "INSERT INTO tasks(%s) VALUES(%s)" + % (", ".join(cols), ", ".join("?" * len(cols))), vals) + conn.commit() + finally: + conn.close() + + def test_goal_mode_task_routes_to_goal_not_dispatch(self): + self._insert("T", goal_mode=1, goal="build it", + goal_max_rounds=0) + seen = [] + + def fake(cmd): + seen.append(list(cmd)) + if "goal" in cmd: + return _goal_met_runner(cmd) + raise AssertionError("goal 任务不应走 dispatch: %s" % cmd) + + worker = Worker(self.db_path, "/fake/host.py", runner=fake, + env=self.env) + result = worker.run_one() + self.assertEqual(result["status"], "completed") + self.assertTrue(any("goal" in cmd for cmd in seen)) + self.assertNotIn("--verify", seen[0]) + self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "0") + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_queue_sandbox.py b/cloud/tests/test_queue_sandbox.py index c7d6ac4..5618d86 100644 --- a/cloud/tests/test_queue_sandbox.py +++ b/cloud/tests/test_queue_sandbox.py @@ -333,11 +333,24 @@ class TestGoalExecutors(unittest.TestCase): self.assertEqual(result["status"], "failed") self.assertIn("请新建任务", result["goal_error"]) - def test_local_goal_task_requires_verify(self): - with self.assertRaises(ValueError): - LocalExecutor("/fake/host.py", - runner=lambda cmd: None).goal_task( - {"id": "t1", "prompt": "do it"}) + def test_local_goal_task_without_verify_is_llm_decided(self): + # 新语义:没有验收命令不再抛错——走模型自决分支: + # 命令里没有 --verify,轮次缺省仍为 3(任务行没写时)。 + seen = [] + + def fake(cmd): + seen.append(list(cmd)) + return SimpleNamespace( + returncode=0, + stdout="goal met after rounds\n", + stderr="") + + result = LocalExecutor("/fake/host.py", + runner=fake).goal_task( + {"id": "t1", "prompt": "do it"}) + self.assertEqual(result["status"], "completed") + self.assertNotIn("--verify", seen[0]) + self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "3") def test_base_goal_task_not_implemented(self): with self.assertRaises(NotImplementedError): diff --git a/cloud/worker.py b/cloud/worker.py index ded13e9..c75ea8d 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -234,6 +234,9 @@ def _task_from_row(row): "goal": _col("goal"), "verify_command": _col("verify_command"), "goal_max_rounds": _col("goal_max_rounds"), + # 新 goal 语义:goal_mode=1 时 prompt 即目标,无验收命令、 + # 不限轮次;老行缺列时 None = 按 verify_command 判定。 + "goal_mode": _col("goal_mode"), # 任务级选项:老行缺列时 None = 跟随配置(见 server 侧)。 "model": _col("model"), "effort": _col("effort"), @@ -467,7 +470,7 @@ class Worker: "SELECT id, project_id, prompt, status, idempotency_key," " depends_on, priority, created_by, created_at, updated_at," " last_heartbeat, executor, goal, verify_command," - " goal_max_rounds FROM tasks" + " goal_max_rounds, goal_mode FROM tasks" " WHERE status IN ('queued', 'completed', 'merged')" " ORDER BY created_at, id").fetchall() finally: @@ -562,6 +565,7 @@ class Worker: "pr_number": "INTEGER", "goal": "TEXT", "verify_command": "TEXT", "goal_max_rounds": "INTEGER", + "goal_mode": "INTEGER", "worktree_host": "TEXT", "model": "TEXT", "effort": "TEXT", @@ -1326,10 +1330,12 @@ class Worker: task_id = task["id"] result = None error = None - # goal 模式:任务带 verify_command 时走 host.py goal 循环, - # 不走普通 dispatch。goal 循环内部已多轮重试,worker 级 + # goal 模式:goal_mode=1(新语义,prompt 即目标)或带 + # verify_command(旧语义)时走 host.py goal 循环,不走 + # 普通 dispatch。goal 循环内部已多轮重试,worker 级 # 的已存在续跑(run)对它没有意义(goal 没有 run 语义)。 - goal_mode = bool(task.get("verify_command")) + goal_mode = bool(task.get("goal_mode") + or task.get("verify_command")) # Docker 同步路径:dispatch 阻塞在 docker run 里时,取消 # 线程靠容器名杀容器(见 cancel())。容器名按任务派生, # 与执行器本身无关,所以这里只记名不碰执行器状态。 @@ -1632,10 +1638,11 @@ class Worker: if self._was_cancelled(task_id) or \ result.get("status") == "cancelled": break - if task.get("verify_command"): + if task.get("verify_command") or task.get("goal_mode"): # goal 模式只跑一次:循环内部已多轮重试(turn→验测→ - # 喂回),worker 级重试/fallback 不再叠加——同任务重跑 - # 会撞 host 任务名已存在,第二次尝试必失败且污染错误文本。 + # 喂回,或新形态的模型自决多轮),worker 级重试/fallback + # 不再叠加——同任务重跑会撞 host 任务名已存在,第二 + # 次尝试必失败且污染错误文本。 break if result.get("status") == "completed": break @@ -1698,7 +1705,8 @@ class Worker: # 剩余 pending 留给重试入口(重试走已存在续跑,跑完继续 # 排空剩余消息);取消时该条回 pending,任务走中断收尾。 if (result is not None and result.get("status") == "completed" - and not task.get("verify_command")): + and not task.get("verify_command") + and not task.get("goal_mode")): while True: fu = self._claim_next_followup(task_id) if fu is None: diff --git a/host.py b/host.py index fbedb99..a527c7f 100644 --- a/host.py +++ b/host.py @@ -1672,18 +1672,40 @@ def run_task_turn_with_retry(server, state, task, prompt, model, effort, return result +GOAL_DONE_MARKER = "目标已达成" + + +def _llm_declared_done(text): + """模型是否宣告目标达成:回合文本里单独一行恰好是标记行。 + + 只有完整跑完的回合才查标记(调用方保证):超时/中断回合的 + 部分文本即使含标记也不算,否则没做完也会提前结束。""" + if not text: + return False + return any(line.strip() == GOAL_DONE_MARKER + for line in text.splitlines()) + + 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 的下一轮续跑,而不是从头重来。 + 两种验收形态(2026-10-27 起,console goal 模式走新形态): + 旧形态 verify 非空:每轮 turn → 跑验收命令 → v_rc==0 达标, + max_rounds 穷尽则 unmet。 + 新形态 verify 为空:不跑验收命令,达标与否由模型自己在回合 + 末尾单独一行输出“目标已达成”宣告;max_rounds=0 表示不限轮次, + 一直跑到模型宣告、预算耗尽或用户取消为止(与 turn_timeout=0 + 不限时同约定)。轮次提示里会把标记约定写明,模型才知道怎么 + 宣告;同一 thread 保留历史,续轮提示可以很短。 + 每轮结束立即落盘(检查点),进程被杀 / 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=...", + (无验收时 verify_rc 记 -,verify_out 为空), 用量行必须是 "usage input=... cached=... ..." 格式,改动时 不能动这三行;两种结局都 exit 0(main 不抛异常即 0)。 预算 stop 是第三种结局:打印 "goal stopped after rounds: ..." @@ -1704,7 +1726,13 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify, ok = False budget_stopped = False goal_usage = None - for round_no in range(int(start_round), int(max_rounds) + 1): + # max_rounds=0 是不限轮次:while 循环只在模型宣告、预算耗尽 + # 或取消时结束;非 0 时按旧语义穷尽轮次即 unmet。 + round_no = int(start_round) - 1 + while True: + round_no += 1 + if int(max_rounds) != 0 and round_no > int(max_rounds): + break if cfg is not None and budget_exceeded(state, cfg): budget_stopped = True print(f"goal stopped: 预算已用完(max_daily_turns)," @@ -1712,15 +1740,34 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify, break if round_no == 1 and not last_verify and not last_turn_status: prompt = goal_text + if verify is None: + # 新形态首轮必须把标记约定写进提示,否则模型不知 + # 道怎么宣告完成,循环永远等不到结束信号。 + prompt = (f"{goal_text}\n\n" + f"完成方式:这是目标循环,你可以多轮工作;" + f"当你确认目标已完全达成时,在回复末尾单独一行输出:" + f"{GOAL_DONE_MARKER}。未达成时不要输出这一行。") elif last_turn_status in ("timeout", "interrupted"): # 上轮回合根本没跑完:说清是回合中断,不是验收失败, # 否则 agent 会对着验收输出修一个不存在的问题。 prompt = (f"上一轮回合未完成(状态:{last_turn_status})," f"请继续完成目标,不要重复已完成的部分。\n" + f"目标:{goal_text}\n") + if verify is not None: + prompt += (f"验收命令:{verify}\n" + f"上一轮验收输出:\n{last_verify}\n") + else: + prompt += (f"确认目标已完全达成时,在回复末尾单独一行输出:" + f"{GOAL_DONE_MARKER}。\n") + prompt += f"上一轮部分文本:\n{last_turn_text[:1000]}" + elif verify is None: + # 新形态续轮:同一 thread 已有历史,只需说明“还没 + # 宣告达成、继续做”,并重申标记约定。 + prompt = (f"上一轮未宣告目标达成,请继续完成目标," + f"不要重复已完成的部分。\n" f"目标:{goal_text}\n" - f"验收命令:{verify}\n" - f"上一轮验收输出:\n{last_verify}\n" - f"上一轮部分文本:\n{last_turn_text[:1000]}") + f"确认目标已完全达成时,在回复末尾单独一行输出:" + f"{GOAL_DONE_MARKER}。") else: # 生产教训:只说“验收未通过” agent 不知道改什么, # 2026-10-25 实测 prod-retry 3 轮都原地不动。 @@ -1737,28 +1784,33 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify, 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]}") + if verify is None: + # 新形态没有验收命令可跑,v_rc 记 -(轮次行格式不变, + # 云端解析只读 verify_out 做 unmet 详情,不受影响)。 + v_rc, v_out = None, "" + else: + 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 @@ -1768,8 +1820,15 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify, 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: + f"verify_rc={v_rc if v_rc is not None else '-'} " + f"verify_out={last_verify[:300]}") + if verify is None: + # 模型自决:只有完整跑完的回合里的标记行才算数。 + if (result["status"] == "completed" + and _llm_declared_done(last_turn_text)): + ok = True + break + elif v_rc == 0: ok = True break if budget_stopped: @@ -1888,8 +1947,13 @@ def main(): p.add_argument("--name", required=True); p.add_argument("--report-to-parent", action="store_true") p = sub.add_parser("goal") 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("--max-rounds", type=int, default=3) + p.add_argument("--repo", default=None) + p.add_argument("--verify", default=None, + help="验收命令;不传时走模型自决:模型在回合末尾" + "单独一行输出“目标已达成”即达标") + p.add_argument("--max-rounds", type=int, default=3, + help="轮次上限;0 表示不限轮次(模型宣告完成才结束)," + "默认 3") p.add_argument("--verify-timeout", type=int, default=600, help="验测命令上限(秒);0 表示不限时,默认 600") p = sub.add_parser("goal-resume") @@ -2489,10 +2553,17 @@ def main(): f"{t.get('status')},已终局或已达标;" f"goal-unmet / goal-met 后请新建任务,不要同任务续跑") return - if not t.get("goal") or not t.get("verify_command"): - print(f"goal-resume 拒绝:{args.task} 没有 goal/verify_command 记录") + if not t.get("goal"): + print(f"goal-resume 拒绝:{args.task} 没有 goal 记录") return - max_rounds = args.max_rounds or t.get("goal_max_rounds") or 3 + # 新形态 verify_command 为空、goal_max_rounds 为 0(不限轮次): + # 0 是合法值不能用 or 兜底,否则续跑会被悄悄改成 3 轮上限。 + if args.max_rounds is not None: + max_rounds = args.max_rounds + elif t.get("goal_max_rounds") is not None: + max_rounds = t["goal_max_rounds"] + else: + max_rounds = 3 srv.resume_thread(t["thread_id"]) last_turn = (t.get("turns") or [{}])[-1] last_result = last_turn.get("result") or {} diff --git a/tests/test_host.py b/tests/test_host.py index 5a80df7..c6a2bcd 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -889,6 +889,97 @@ class GoalLoopLongTaskTest(unittest.TestCase): self.assertEqual(task["status"], "goal-unmet") self.assertIn("verify timeout", task["goal_last_verify"]) + def test_goal_llm_decided_met_on_marker_unlimited(self): + # 新形态:verify=None、max_rounds=0(不限轮次);首轮提示 + # 含标记约定;第 2 轮模型输出标记行即达标。 + 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": "completed", "text": "working", + "turn_id": "u1"} + return {"status": "completed", + "text": "done\n目标已达成", + "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", None, 0, "m", + "low", None, cfg={}, timeout=600) + self.assertTrue(result["ok"]) + self.assertEqual(task["status"], "goal-met") + self.assertEqual(task["goal_rounds_done"], 2) + self.assertEqual(task["goal_max_rounds"], 0) + self.assertIn("目标已达成", srv.prompts[0]) + self.assertIn("目标已达成", srv.prompts[1]) + + def test_goal_llm_decided_marker_ignored_on_timeout_turn(self): + # 超时回合的部分文本即使含标记行也不算达标,必须继续跑。 + class FakeServer: + def __init__(self): + self.calls = 0 + + def run_turn(self, thread_id, prompt, model=None, effort=None, + sandbox=None, timeout=None): + self.calls += 1 + if self.calls == 1: + return {"status": "timeout", + "text": "partial\n目标已达成", + "turn_id": "u1"} + return {"status": "completed", + "text": "done\n目标已达成", + "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 + result = host.run_goal_loop(FakeServer(), state, state_path, + task, "make it", None, 0, "m", + "low", None, cfg={}, timeout=600) + self.assertTrue(result["ok"]) + self.assertEqual(task["status"], "goal-met") + self.assertEqual(task["goal_rounds_done"], 2) + + def test_goal_llm_decided_unlimited_past_three_rounds(self): + # 0 = 不限轮次:第 4 轮才宣告也照常结束,不会被旧的 + # 默认 3 轮上限截断。 + class FakeServer: + def __init__(self): + self.calls = 0 + + def run_turn(self, thread_id, prompt, model=None, effort=None, + sandbox=None, timeout=None): + self.calls += 1 + if self.calls < 4: + return {"status": "completed", "text": "working", + "turn_id": "u%d" % self.calls} + return {"status": "completed", + "text": "done\n目标已达成", + "turn_id": "u4"} + + 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", None, 0, "m", + "low", None, cfg={}, timeout=600) + self.assertTrue(result["ok"]) + self.assertEqual(task["status"], "goal-met") + self.assertEqual(task["goal_rounds_done"], 4) + class ProgressInterruptTest(unittest.TestCase):