feat: goal 模式新语义——prompt 即目标,无验收命令,不限轮次,模型宣告完成 (#67)

console 打勾 goal 模式时不再有专用 goal 文本、验收命令、轮次
三个输入(已移除,换成说明行),只发 goal_mode=1;server 侧
goal_mode=1 时 goal 存 prompt、验收命令空、轮次存 0,带验收
命令或与计划模式同用时 400,旧的带验收命令任务照原样可用。

host.py goal 循环:verify 为空时不跑验测,轮次提示里写明
标记约定;模型在完整跑完的回合末尾单独一行输出“目标已
达成”即达标(超时回合的部分文本含标记也不算);--max-rounds
0 为不限轮次,goal-resume 续跑按 0 处理(不用 or 兜底成 3)。
刹车只有取消与预算耗尽(后者记 interrupted、可续跑)。

迁移与 worker:tasks 加 goal_mode 列(server 迁移、worker 列
补齐、PG schema 三处同步);worker 按 goal_mode 或
verify_command 判定 goal 路径,重试与后续消息排空对新语义
同样只跑一次/不排空;Local/Docker goal_task 的 --verify 可选、
--max-rounds 0 原样传。

Co-authored-by: host <host@test.local>
This commit is contained in:
openqjandhost authored and GitHub committed 2026-10-27 03:54:48 +08:00
1 parent 4179fb1a0a
commit c86b450851
10 files changed
+519 -108

No files matched your search

+55 -33
View File
@@ -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):