code-to10 goal live update: POST /tasks/{id}/goal updates a running goal loop at the next round boundary

Server writes goal + monotonic goal_version + task_goal_history in one
transaction (CAS on version, approve_merge only, new-semantic goal tasks
only, terminal/merged 409, same-goal noop). Worker mirror thread (30s)
and breakpoint-resume claim hook write the update as a signal file next
to host-state.json; host run_goal_loop applies it at round boundaries
(top of round and before the declaration check, so a declaration made
for the old goal in a turn during which the goal changed is voided),
with an explicit old->new notice, same thread/worktree, rounds/usage/
budget/failed counters untouched. handler method accounting is a
contract update: 239 total / 223 mixins / 16 in server.py.

(cherry picked from commit 6841f287219d2b36cfbb8803642344b84dae4377)

Reconcile-port: handler_lifecycle.py contents redirected — _update_goal_task method + _goal_update_reject/_goal_signal_status pure functions + goal_live_update import into cloud/server.py (target keeps 6-mixin split); _task_to_dict goal fields into cloud/handler_support.py; migrate columns into cloud/server.py migrate_tasks_table; test patch target handler_lifecycle→server; test_handler_split_conformance.py kept at target version, to be re-pinned for the unified line in a final commit.
This commit is contained in:
host committed 2026-11-03 02:49:27 +00:00
1 parent 63abb3953b
commit 9e56c1d266
6 files changed
+1110 -2

No files matched your search

+120 -1
View File
@@ -74,6 +74,10 @@ from cloud.db import init_postgres_schema as _init_postgres_schema
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.goal_command import parse_goal_command as _parse_goal_command
from cloud.goal_live_update import (
apply_goal_update as _glu_apply_goal_update,
ensure_goal_history_table as _glu_ensure_history,
)
from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
@@ -1118,7 +1122,8 @@ def migrate_tasks_table(conn):
finished_at、child_pid、executor、pr_url、pr_number,以及
goal、verify_command、goal_max_rounds(goal 模式用)、
goal_mode(新 goal 语义:1 时 prompt 即目标,无验收命令、
不限轮次;老行 NULL 按 0 计)。
不限轮次;老行 NULL 按 0 计),以及运行中改 goal 的
goal_version、applied_goal_version(老行 NULL 按 0 计)。
PRAGMA table_info 查现有列,缺哪个补哪个;已迁移的库再跑
一次是 no-op。worker 写执行结果、server 读任务时都依赖
这些列存在。
@@ -1140,6 +1145,10 @@ def migrate_tasks_table(conn):
"verify_command": "TEXT",
"goal_max_rounds": "INTEGER",
"goal_mode": "INTEGER",
# 运行中改 goal:goal_version 已提交版本、applied_goal_version
# 循环已应用版本(老行 NULL 按 0 计,见 _task_to_dict)。
"goal_version": "INTEGER",
"applied_goal_version": "INTEGER",
# HA 阶段 1:认领任务的 worker 所在主机名;/pr 据此判断
# worktree 在本机(同步跑)还是别机(排 pr 任务给该主机)。
"worktree_host": "TEXT",
@@ -2209,6 +2218,37 @@ def _thread_non_goal_guard(task):
"status": task["status"]}
return None
def _goal_update_reject(task, new_goal):
"""运行中改 goal 的拒绝判定(纯函数,不碰 DB)。
返回 (status, error) 或 (None, None):非新语义 goal 任务
(goal_mode 不为 1,或 verify_command 非空)409——旧验收
命令形态不在本设计内,绝不静默转换(verify_argv 只活在
host-state 里、不是任务表列,服务端以 verify_command 把关,
host 循环有验收时一律忽略信号);已终态 409(终态走复活路)。
逐字相同是 no-op,不是拒绝,由调用方单独处理。
"""
if not task.get("goal_mode") or task.get("verify_command"):
return (409, "旧验收命令形态不在运行中改 goal 设计内")
if task["status"] in ("completed", "failed", "merged",
"merge-failed", "cancelled"):
return (409, "已终态任务请走复活路,不要用运行中改 goal")
return (None, None)
def _goal_signal_status(status):
"""改 goal 成功后,信号投递状态(仅作可见性,不冒充已应用)。
queued 只改库(下次认领自然读新 goal);interrupted 等
续跑认领钩子写信号;running 等 worker 镜像线程写信号。
"""
if status == "queued":
return "queued_no_signal_needed"
if status == "interrupted":
return "pending_resume_signal"
return "pending_mirror_to_host_loop"
class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin,
BaseHTTPRequestHandler):
"""控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。"""
@@ -2367,6 +2407,8 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
self._retry_task(user_id, rest[: -len("/retry")])
elif rest.endswith("/goal-resume") and method == "POST":
self._goal_resume_task(user_id, rest[: -len("/goal-resume")])
elif rest.endswith("/goal") and method == "POST":
self._update_goal_task(user_id, rest[: -len("/goal")])
elif rest.endswith("/auto-memory-flags") and method == "POST":
self._task_auto_memory_flags(
user_id, rest[: -len("/auto-memory-flags")])
@@ -7502,6 +7544,83 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
"task_goal_resumed", task_id)
self._send_json(200, task)
def _update_goal_task(self, user_id, task_id):
"""运行中改 goal:POST /tasks/{id}/goal。
只改目标文本,不改任务状态、不建新任务、不开新分支、
不重置轮次/用量/预算/失败计数。生效路径:queued 只改库;
running 由 worker 镜像线程(每 30 秒)写信号,host 循环
在下一个轮次边界应用,绝不在正在跑的一轮中途打断;
interrupted 由续跑认领钩子写信号,断点轮次的下一轮应用。
拒绝口径见 _goal_update_reject;逐字相同是 no-op(不
bump 版本、不写 history、不写审计);改 goal 本身不花
模型钱,不查配额;返回体含 goal_update 与
goal_signal_status,goal_version(已提交)与
applied_goal_version(已应用)不等时绝不冒充已应用。
"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
if not isinstance(body, dict):
self._send_json(400, {"error": "body must be a JSON object"})
return
new_goal = body.get("goal")
if not isinstance(new_goal, str) or not new_goal.strip():
self._send_json(400, {"error": "goal 不能为空"})
return
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
reject_status, reject_error = _goal_update_reject(task, new_goal)
if reject_status is not None:
self._send_json(reject_status, {
"error": reject_error, "status": task["status"]})
return
current_goal = task.get("goal") or task.get("prompt") or ""
if new_goal == current_goal:
self._send_json(200, dict(
task, goal_update="noop",
goal_signal_status="noop_same_goal"))
return
now = time.time()
conn = self._db()
try:
migrate_tasks_table(conn)
_glu_ensure_history(conn)
new_version = _glu_apply_goal_update(
conn, task_id, current_goal,
task.get("goal_version") or 0, new_goal, user_id, now)
if new_version is None:
conn.rollback()
self._send_json(409, {
"error": "任务已变化,请刷新后重试",
"status": task["status"],
})
return
conn.commit()
task = _task_to_dict(
conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)
).fetchone()
)
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"task_goal_updated",
"%s:goal_version=%s" % (task_id, new_version))
self._send_json(200, dict(
task, goal_update="updated",
goal_signal_status=_goal_signal_status(task["status"])))
def _retry_task(self, user_id, task_id):
"""把终态失败任务重新排队:只改状态,不直接执行。