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

+109
View File
@@ -168,6 +168,9 @@ from cloud.sandbox_policy import cleanup_decision as _cleanup_decision
from cloud.timeouts import STALE_SWEEP_SECONDS as _STALE_SWEEP
from cloud.db import connect as _db_connect
from cloud.goal_command import parse_goal_command as _parse_goal_command
from cloud.goal_live_update import (
write_goal_update_signal as _glu_write_signal,
)
from cloud.logging_config import get_logger, log_event, setup_logging
from cloud.models import ModelRouter, is_fallbackable, record_fallback
from cloud.auto_memory import AutoMemoryStore
@@ -433,6 +436,10 @@ def _task_from_row(row):
# 新 goal 语义:goal_mode=1 时 prompt 即目标,无验收命令、
# 不限轮次;老行缺列时 None = 按 verify_command 判定。
"goal_mode": _col("goal_mode"),
# 运行中改 goal:已提交版本与循环已应用版本,老行缺列
# 时按 0 计(= 从未改过 goal,信号版本从 1 起算)。
"goal_version": _col("goal_version") or 0,
"applied_goal_version": _col("applied_goal_version") or 0,
# 任务级选项:老行缺列时 None = 跟随配置(见 server 侧)。
"model": _col("model"),
"effort": _col("effort"),
@@ -1270,6 +1277,68 @@ class Worker:
except Exception:
return None
def _write_goal_signal_if_newer(self, task, db_goal, db_version):
"""运行中改 goal 的信号写侧(worker 侧唯一写点)。
state 文件里的 applied_goal_version 是 host 循环上次
检查点落盘的已应用版本;库里 goal_version 更大时,
把新 goal 写成信号文件(host-state 同目录的
goal-updates/<host 名>.json,host 循环在轮次边界读)。
任何异常一律返回 False,信号写失败绝不能炸掉任务
执行或镜像本身。state 文件不存在(Local/Docker 的
work 挂载还没建起来、或 CloudExecutor 根本没有
host-state)时同样返回 False,不猜路径。
"""
try:
if not db_goal or not isinstance(db_goal, str):
return False
version = int(db_version or 0)
if version < 1:
return False
state_path = self._host_state_path_for_task(task)
if not state_path or not os.path.exists(state_path):
return False
with open(state_path, encoding="utf-8") as fh:
state = json.load(fh)
host_name = _worker_host_name(self.executor, task)
entry = (state.get("tasks") or {}).get(host_name)
if not isinstance(entry, dict):
return False
applied = int(entry.get("applied_goal_version") or 0)
if version <= applied:
return False
_glu_write_signal(state_path, host_name, version,
db_goal, task.get("id"))
return True
except Exception:
return False
def _sync_resume_goal_signal(self, task):
"""断点续跑认领时钩子:库里新 goal 先写成信号再起循环。
镜像线程只在循环跑起来之后才工作,已中断任务没有
在跑的镜像线程,所以认领断点续跑时必须先读一次库:
库里 goal_version 大于 state 文件已应用版本时写信号,
host 循环在断点轮次的下一轮(第一个轮次边界)应用
它,轮次不断点、不回到第 1 轮。任何异常返回 False,
续跑本身照常走断点(用旧 goal),不因信号失败而炸。
"""
try:
conn = _db_connect(self.db_path)
try:
row = conn.execute(
"SELECT goal, goal_version FROM tasks WHERE id = ?",
(task["id"],)).fetchone()
finally:
conn.close()
if row is None:
return False
goal = row[0] if len(row) > 0 else None
version = row[1] if len(row) > 1 else None
return self._write_goal_signal_if_newer(task, goal, version)
except Exception:
return False
def _mirror_checkpoint_once(self, task):
"""把磁盘 host-state 里的本任务断点镜像进库(单次)。
@@ -1302,6 +1371,34 @@ class Worker:
conn = _db_connect(self.db_path)
try:
self._ensure_result_columns(conn)
# 运行中改 goal 可见性(只在变化时写):host 循环
# 检查点里的 applied_goal_version 回写任务行,
# 调用方据此区分“已提交的新版本”与“循环已应用
# 的版本”,两者不等时绝不冒充已应用。
if entry.get("applied_goal_version") is not None:
try:
applied_v = int(entry["applied_goal_version"])
except (TypeError, ValueError):
applied_v = None
if applied_v is not None:
conn.execute(
"UPDATE tasks SET applied_goal_version = ?"
" WHERE id = ? AND (applied_goal_version"
" IS NULL OR applied_goal_version <> ?)",
(applied_v, task["id"], applied_v))
# 运行中改 goal 反向信号:库里 goal_version 大于
# state 已应用版本时写信号文件,host 循环在下个
# 轮次边界应用;读库失败时跳过(下个 30 秒周期
# 再试),信号写失败由 helper 内部吞掉。
try:
db_row = conn.execute(
"SELECT goal, goal_version FROM tasks"
" WHERE id = ?", (task["id"],)).fetchone()
except Exception:
db_row = None
if db_row is not None:
self._write_goal_signal_if_newer(
task, db_row[0], db_row[1])
existing = conn.execute(
"SELECT 1 FROM task_checkpoints"
" WHERE task_id = ? AND round_no = ?",
@@ -1525,6 +1622,9 @@ class Worker:
"verify_command": "TEXT",
"goal_max_rounds": "INTEGER",
"goal_mode": "INTEGER",
# 运行中改 goal:与 server.migrate_tasks_table 同列。
"goal_version": "INTEGER",
"applied_goal_version": "INTEGER",
"worktree_host": "TEXT",
"claimed_by_worker": "TEXT",
"model": "TEXT",
@@ -4688,6 +4788,15 @@ class Worker:
except Exception:
_mirror_stop = None
_mirror_thread = None
# 运行中改 goal 认领钩子:断点续跑时镜像线程还没跑,
# 必须在起循环之前先把库里新 goal 写成信号,否则断点
# 续跑会沿用 host-state 里的旧 goal。钩子只写信号,
# 失败时续跑照常用旧 goal,不炸执行(见 helper 文档)。
if goal_mode and task.get("_goal_resume_breakpoint"):
try:
self._sync_resume_goal_signal(task)
except Exception:
pass
if is_docker and hasattr(self.executor, "kill_container"):
try:
_container_name = self.executor.container_name(task)