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:
1 parent
63abb3953b
commit
9e56c1d266
6 files changed
+1110
-2
No files matched your search
+120
-1
@@ -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):
|
||||
"""把终态失败任务重新排队:只改状态,不直接执行。
|
||||
|
||||
|
||||
Reference in new issue
Block a user