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

+172 -1
View File
@@ -129,6 +129,95 @@ def _interrupt_request_path(state_path, task_name):
"interrupt-requests", safe + ".json")
def _goal_update_signal_path(state_path, task_name):
"""运行中改 goal 的信号文件路径(与 cloud/goal_live_update.py
同款格式,改一处必须同步另一处,跨模块测试钉住它):
<state_dir>/goal-updates/<task>.json,内容是
{"version": int, "goal": str, "task_id": str | None}。
只由 worker 侧(镜像线程 / 断点认领钩子)写,host 循环
只在轮次边界读,绝不在正在跑的一轮中途读它。
"""
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task_name))
return os.path.join(
os.path.dirname(os.path.abspath(state_path)),
"goal-updates", safe + ".json")
def _read_goal_update_signal(state_path, task_name, applied_version):
"""读信号,返回 (version, goal) 或 None。
文件不存在、JSON 坏、version 不是正整数、goal 不是非空
字符串、version <= 已应用版本时一律返回 None,绝不抛。
"""
try:
applied = int(applied_version or 0)
except (TypeError, ValueError):
applied = 0
try:
with open(_goal_update_signal_path(state_path, task_name),
encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
return None
if not isinstance(data, dict):
return None
version = data.get("version")
goal = data.get("goal")
if not isinstance(version, int) or isinstance(version, bool) \
or version < 1:
return None
if not isinstance(goal, str) or not goal.strip():
return None
if version <= applied:
return None
return (version, goal)
def _consume_goal_update_signal(state_path, task_name, applied_version):
"""已应用版本的信号文件尽力删掉(删失败静默)。
只删 version <= 已应用版本的信号;更新的信号(写于应用
之后、尚未应用)绝不能被误删。
"""
try:
applied = int(applied_version or 0)
except (TypeError, ValueError):
applied = 0
path = _goal_update_signal_path(state_path, task_name)
try:
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
return False
if not isinstance(data, dict):
return False
version = data.get("version")
if not isinstance(version, int) or isinstance(version, bool):
return False
if version > applied:
return False
try:
os.remove(path)
except Exception:
return False
return True
def _goal_update_notice(old_goal, new_goal):
"""轮次边界应用新 goal 时喂给 agent 的显式通知块(与
cloud/goal_live_update.build_goal_update_notice 同款文案)。
"""
return (
"目标已更新(运行中改 goal):\n"
"旧目标:%s\n"
"新目标:%s\n"
"请继续完成新目标,不要重复已完成的部分;完成与否只按"
"新目标判断;此前如已输出过“目标已达成”,该宣告对新"
"目标作废——新目标满足时请重新宣告,否则继续工作。"
% (old_goal, new_goal)
)
def _wait_turn(q, timeout, progress_interval=30, on_progress=None,
interrupt_check=None):
"""等 turn/completed 的轮询等待(对齐 Codex App 的长等待语义)。
@@ -2680,6 +2769,15 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
task["goal_max_rounds"] = int(max_rounds)
task["goal_rounds_done"] = int(start_round) - 1
task["goal_last_verify"] = last_verify
# 运行中改 goal:已应用版本只活在 state 检查点里(断点
# 续跑时从 state 条目恢复),老条目缺字段时按 0 计;新
# 循环的 spawn goal 本身就是版本 0 的起点,信号版本从 1 起。
try:
applied_goal_version = int(task.get("applied_goal_version") or 0)
except (TypeError, ValueError):
applied_goal_version = 0
task["applied_goal_version"] = applied_goal_version
pending_goal_notice = None
# 循环前先落盘一次:否则第一轮 turn 进行中被杀时,磁盘上的
# 任务还没有 goal/verify_command,goal-resume 会因“没有记录”
# 而拒绝——断点续跑必须在第一轮开始前就已可续跑。
@@ -2710,6 +2808,38 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
"rounds_done": task["goal_rounds_done"],
"last_verify": last_verify, "usage": goal_usage}
def _apply_goal_update():
"""运行中改 goal 的轮次边界应用(只换内存里的 goal)。
有验收的循环一律忽略信号(旧验收命令形态不在运行中
改 goal 设计内,服务端已用 verify_command 把关,argv
形态只活在 host-state 里,这里是第二道门)。信号版本
大于已应用版本时才换:goal_text、task["goal"]、
applied_goal_version 一起换,并留一条待用通知给本轮
或下一轮的提示。落盘由本轮检查点做(换后、检查点前
被杀时 state 里仍是旧版本、信号文件还在,断点续跑
会重新应用,绝不丢更新);轮次计数、用量、连续失败
计数、预算状态一律不动。
"""
nonlocal goal_text, applied_goal_version, pending_goal_notice
if has_verify:
return False
task_name = task.get("name")
if not task_name:
return False
sig = _read_goal_update_signal(
state_path, task_name, applied_goal_version)
if sig is None:
return False
version, new_goal = sig
old_goal = goal_text
goal_text = new_goal
applied_goal_version = version
task["goal"] = new_goal
task["applied_goal_version"] = version
pending_goal_notice = _goal_update_notice(old_goal, new_goal)
return True
# max_rounds=0 是不限轮次:while 循环只在模型宣告、预算耗尽
# 或取消时结束;非 0 时按旧语义穷尽轮次即 unmet。
round_no = int(start_round) - 1
@@ -2728,6 +2858,10 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
print(f"goal stopped: 预算已用完(max_daily_turns),"
f"第 {round_no} 轮未开始,任务可续跑")
break
# 运行中改 goal 检查点一:轮次开始前(构造本轮提示
# 之前)应用信号。正在跑的一轮绝不在中途被打断,
# 信号只影响从本轮起的提示。
_apply_goal_update()
if round_no == 1 and not last_verify and not last_turn_status:
prompt = goal_text
if not has_verify:
@@ -2766,6 +2900,11 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
f"目标:{goal_text}\n"
f"验收命令:{verify_text}\n"
f"上一轮验收输出:\n{last_verify}")
if pending_goal_notice is not None:
# 本轮提示显式写出旧 goal → 新 goal;通知只用一次,
# 下一轮提示是重新构造的,不重复贴。
prompt = pending_goal_notice + "\n\n" + prompt
pending_goal_notice = None
try:
result = run_task_turn(server, state, task, prompt,
model, effort, sandbox, timeout=timeout,
@@ -2829,6 +2968,12 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
save_state(state_path, state)
except Exception as exc:
return _interrupt_loop(exc)
# 已应用版本的信号文件尽力清掉;更新的信号(写于本
# 轮检查点之后、尚未应用)版本号大于已应用版本,
# 清理函数不会误删它。
if task.get("name"):
_consume_goal_update_signal(
state_path, task["name"], applied_goal_version)
print(f"round {round_no}: turn={result['status']} "
f"verify_rc={v_rc if v_rc is not None else '-'} "
f"verify_out={last_verify[:300]}")
@@ -2851,9 +2996,17 @@ def run_goal_loop(server, state, state_path, task, goal_text, verify,
ensure_ascii=False) + "\n")
except Exception:
pass
# 运行中改 goal 检查点二:回合结束后、宣告判定之前
# 再查一次信号。本轮进行中提交的新 goal 会在这里
# 应用:本轮对旧 goal 的宣告作废,不计入新 goal 的
# 完成判定,循环继续到下一轮用新 goal 提示;轮次计数、
# 用量、连续失败计数一律不动。有验收的循环不应用
# 信号(_apply_goal_update 内部已挡),判定原样。
goal_updated_after_turn = _apply_goal_update()
if not has_verify:
# 模型自决:只有完整跑完的回合里的标记行才算数。
if (result["status"] == "completed"
if (not goal_updated_after_turn
and result["status"] == "completed"
and _llm_declared_done(last_turn_text)):
ok = True
break
@@ -4018,6 +4171,14 @@ def main():
args.approval_policy,
seed_dir=args.seed_dir)
save_state(args.state, state)
# 新开循环的 spawn goal 是权威的:上一轮同名循环
# 残留的信号文件必须清掉,否则首轮边界会把它当
# 新信号应用、覆盖刚创建的 goal。循环跑起来之后
# 镜像线程会按需重写信号,清早期的不丢更新。
try:
os.remove(_goal_update_signal_path(args.state, args.name))
except Exception:
pass
if args.verify and args.verify_argv:
print("verify_argv 已指定,--verify 字符串被忽略")
run_goal_loop(srv, state, args.state, task, args.task,
@@ -4061,6 +4222,16 @@ def main():
return
max_rounds = args.max_rounds \
if args.max_rounds is not None else 0
# 开新目标的 --goal-text 是权威的:残留的运行中
# 改 goal 信号必须清掉,否则首轮边界会把它当新
# 信号应用、覆盖这次明确传入的新目标。断点续跑
# 分支(不传 --goal-text)绝不能清——信号正是
# 已中断任务改 goal 后续跑的投递通道。
try:
os.remove(_goal_update_signal_path(
args.state, args.task))
except Exception:
pass
srv.resume_thread(t["thread_id"])
run_goal_loop(srv, state, args.state, t, args.goal_text,
None, max_rounds,