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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user