Files
codex-app-server/cloud/tests/test_goal_live_update.py
T
host 9e56c1d266 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.
2026-11-03 02:49:27 +00:00

510 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运行中改 goal:端点、信号格式、worker 桥、host 循环。
只用假 turn / 假 state / 临时 SQLite,真实模型 0 轮,不联网,
不碰生产。host 循环的假 turn 直接调 host.run_task_turn 的
桩,host.py 本身可 import(顶层只有定义 + __main__ 守卫)。
覆盖任务书逐项:403、空 goal 400、相同 no-op(版本与
history 不变)、终态/merged 409、旧验收形态 409、queued
只改库、运行中信号在轮次边界生效且轮次/用量/失败计数不
重置、旧宣告作废(新 goal 下需重新宣告)、过期版本被丢弃、
最新版胜出、已中断续跑带新 goal 且轮次不断点。
"""
import contextlib
import json
import os
import sqlite3
import sys
import tempfile
import time
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
import host as host_mod # noqa: E402
from cloud import goal_live_update as glu # noqa: E402
from cloud import server as lifecycle_mod # noqa: E402
# Reconcile-retarget: _update_goal_task lives in cloud.server on this
# line (target keeps the 6-mixin split); the test patches _auth_store
# in the module where the method resolves it, i.e. cloud.server.
from cloud.server import CloudControlPlaneHandler, init_db # noqa: E402
from cloud.worker import Worker # noqa: E402
def _handler(db_path, body, permitted=True):
h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler)
h.server = SimpleNamespace(db_path=db_path)
sent = []
h._send_json = lambda status, obj, extra_headers=None: sent.append(
(status, obj))
h._read_json = lambda: (body, None)
h.sent = sent
audits = []
class FakeStore:
def check_permission(self, user_id, project_id, action):
return permitted and action == "approve_merge"
def audit(self, user_id, project_id, action, detail=""):
audits.append((user_id, project_id, action, detail))
@contextlib.contextmanager
def fake_auth_store(_db_path):
yield FakeStore()
h._audits = audits
return h, fake_auth_store
def _insert_task(db_path, task_id="T1", status="running",
goal="old goal", goal_mode=1, verify_command=None,
goal_version=0, applied=0):
now = time.time()
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, goal, goal_mode,"
" verify_command, goal_max_rounds, goal_version,"
" applied_goal_version) VALUES(?, 'p1', ?, ?, NULL, '[]',"
" 0, 'tester', ?, ?, NULL, ?, ?, ?, 0, ?, ?)",
(task_id, goal, status, now, now, goal, goal_mode,
verify_command, goal_version, applied))
conn.commit()
finally:
conn.close()
def _task_row(db_path, task_id="T1"):
conn = sqlite3.connect(db_path)
try:
conn.row_factory = sqlite3.Row
return dict(conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone())
finally:
conn.close()
def _history(db_path, task_id="T1"):
conn = sqlite3.connect(db_path)
try:
return conn.execute(
"SELECT old_goal, new_goal, version, created_by FROM"
" task_goal_history WHERE task_id = ? ORDER BY version",
(task_id,)).fetchall()
finally:
conn.close()
class SignalFormatTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.state_path = os.path.join(self.tmp.name, "host-state.json")
with open(self.state_path, "w", encoding="utf-8") as fh:
json.dump({"tasks": {}}, fh)
self.host_name = "codex-abc123"
def tearDown(self):
self.tmp.cleanup()
def test_cloud_and_host_signal_paths_agree(self):
self.assertEqual(
glu.signal_path(self.state_path, self.host_name),
host_mod._goal_update_signal_path(
self.state_path, self.host_name))
def test_write_read_latest_wins_and_stale_dropped(self):
glu.write_goal_update_signal(
self.state_path, self.host_name, 2, "goal v2", "T1")
# 版本 3 覆盖版本 2:只应用最新版,中间版本不进循环。
glu.write_goal_update_signal(
self.state_path, self.host_name, 3, "goal v3", "T1")
self.assertEqual(
glu.read_goal_update_signal(self.state_path, self.host_name, 1),
(3, "goal v3"))
# 已应用版本 3 后,同一文件即过期。
self.assertIsNone(
glu.read_goal_update_signal(self.state_path, self.host_name, 3))
# host 读侧同款判定。
self.assertIsNone(
host_mod._read_goal_update_signal(
self.state_path, self.host_name, 3))
def test_bad_signal_never_raises(self):
path = glu.signal_path(self.state_path, self.host_name)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
fh.write("{not json")
self.assertIsNone(
glu.read_goal_update_signal(self.state_path, self.host_name, 0))
with open(path, "w", encoding="utf-8") as fh:
json.dump({"version": 5, "goal": " "}, fh)
self.assertIsNone(
glu.read_goal_update_signal(self.state_path, self.host_name, 0))
with open(path, "w", encoding="utf-8") as fh:
json.dump({"version": "5", "goal": "x"}, fh)
self.assertIsNone(
glu.read_goal_update_signal(self.state_path, self.host_name, 0))
def test_consume_only_deletes_applied_or_older(self):
glu.write_goal_update_signal(
self.state_path, self.host_name, 4, "goal v4", "T1")
self.assertFalse(glu.consume_goal_update_signal(
self.state_path, self.host_name, 3))
self.assertTrue(os.path.exists(
glu.signal_path(self.state_path, self.host_name)))
self.assertTrue(glu.consume_goal_update_signal(
self.state_path, self.host_name, 4))
self.assertFalse(os.path.exists(
glu.signal_path(self.state_path, self.host_name)))
def test_notice_mentions_old_new_and_void(self):
notice = glu.build_goal_update_notice("old goal", "new goal")
self.assertIn("old goal", notice)
self.assertIn("new goal", notice)
self.assertIn("作废", notice)
self.assertEqual(
notice, host_mod._goal_update_notice("old goal", "new goal"))
class EndpointTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "test.db")
init_db(self.db_path)
def tearDown(self):
self.tmp.cleanup()
def _call(self, task_id, body, permitted=True):
h, fake_auth = _handler(self.db_path, body, permitted)
with mock.patch.object(lifecycle_mod, "_auth_store", fake_auth):
h._update_goal_task("tester", task_id)
return h.sent[-1]
def test_forbidden_without_approve_merge(self):
_insert_task(self.db_path)
status, _ = self._call("T1", {"goal": "new goal"},
permitted=False)
self.assertEqual(status, 403)
self.assertEqual(_task_row(self.db_path)["goal"], "old goal")
def test_empty_goal_400(self):
_insert_task(self.db_path)
status, _ = self._call("T1", {"goal": " "})
self.assertEqual(status, 400)
def test_same_goal_noop_changes_nothing(self):
_insert_task(self.db_path, goal_version=2, applied=2)
status, obj = self._call("T1", {"goal": "old goal"})
self.assertEqual(status, 200)
self.assertEqual(obj["goal_update"], "noop")
row = _task_row(self.db_path)
self.assertEqual(row["goal_version"], 2)
# history 表由改 goal 端点建表,no-op 路不建表也不写行:
# 先建表再断言为空,才是“no-op 没写 history”的证据。
conn = sqlite3.connect(self.db_path)
try:
glu.ensure_goal_history_table(conn)
conn.commit()
finally:
conn.close()
self.assertEqual(_history(self.db_path), [])
def test_terminal_and_merged_409(self):
for status_name in ("completed", "failed", "merged",
"merge-failed", "cancelled"):
_insert_task(self.db_path, task_id="X-" + status_name,
status=status_name)
status, _ = self._call("X-" + status_name,
{"goal": "new goal"})
self.assertEqual(status, 409, status_name)
def test_old_verify_form_409(self):
_insert_task(self.db_path, verify_command="make check")
status, _ = self._call("T1", {"goal": "new goal"})
self.assertEqual(status, 409)
_insert_task(self.db_path, task_id="T2", goal_mode=0)
status, _ = self._call("T2", {"goal": "new goal"})
self.assertEqual(status, 409)
def test_queued_only_updates_db_and_history(self):
_insert_task(self.db_path, status="queued")
status, obj = self._call("T1", {"goal": "new goal"})
self.assertEqual(status, 200)
self.assertEqual(obj["goal_update"], "updated")
self.assertEqual(obj["goal_signal_status"],
"queued_no_signal_needed")
row = _task_row(self.db_path)
self.assertEqual(row["goal"], "new goal")
self.assertEqual(row["goal_version"], 1)
self.assertEqual(row["applied_goal_version"], 0)
self.assertEqual(_history(self.db_path),
[("old goal", "new goal", 1, "tester")])
def test_running_and_interrupted_signal_status(self):
_insert_task(self.db_path, status="running")
_, obj = self._call("T1", {"goal": "new goal"})
self.assertEqual(obj["goal_signal_status"],
"pending_mirror_to_host_loop")
_insert_task(self.db_path, task_id="T2", status="interrupted")
_, obj = self._call("T2", {"goal": "new goal"})
self.assertEqual(obj["goal_signal_status"],
"pending_resume_signal")
def test_missing_task_404(self):
status, _ = self._call("NOPE", {"goal": "new goal"})
self.assertEqual(status, 404)
class WorkerBridgeTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "test.db")
init_db(self.db_path)
self.state_dir = os.path.join(self.tmp.name, "work")
os.makedirs(self.state_dir, exist_ok=True)
self.state_path = os.path.join(self.state_dir, "host-state.json")
self.task = {"id": "T1", "project_id": "p1"}
self.host_name = "codex-abc123"
def tearDown(self):
self.tmp.cleanup()
def _worker(self):
w = Worker.__new__(Worker)
w.db_path = self.db_path
w.tenant_isolation = False
w.tenants_root = os.path.join(self.tmp.name, "tenants")
executor = SimpleNamespace(
extra_mounts=[(self.state_dir, "/app/work")],
config_path=None)
executor._host_name = lambda task: self.host_name
w.executor = executor
return w
def _write_state(self, applied):
with open(self.state_path, "w", encoding="utf-8") as fh:
json.dump({"tasks": {self.host_name: {
"name": self.host_name, "thread_id": "th1",
"goal": "old goal", "goal_rounds_done": 2,
"applied_goal_version": applied,
"status": "goal-running"}}}, fh)
def test_resume_hook_writes_signal_when_db_newer(self):
_insert_task(self.db_path, status="interrupted",
goal="new goal", goal_version=3)
self._write_state(applied=1)
w = self._worker()
self.assertTrue(w._sync_resume_goal_signal(self.task))
self.assertEqual(
glu.read_goal_update_signal(
self.state_path, self.host_name, 1), (3, "new goal"))
def test_resume_hook_no_signal_when_not_newer(self):
_insert_task(self.db_path, status="interrupted",
goal="old goal", goal_version=1)
self._write_state(applied=1)
w = self._worker()
self.assertFalse(w._sync_resume_goal_signal(self.task))
self.assertIsNone(glu.read_goal_update_signal(
self.state_path, self.host_name, 1))
def test_mirror_writes_signal_and_reports_applied(self):
_insert_task(self.db_path, status="running",
goal="new goal", goal_version=2)
self._write_state(applied=1)
w = self._worker()
rounds = w._mirror_checkpoint_once(self.task)
self.assertEqual(rounds, 2)
self.assertEqual(
glu.read_goal_update_signal(
self.state_path, self.host_name, 1), (2, "new goal"))
row = _task_row(self.db_path)
self.assertEqual(row["applied_goal_version"], 1)
def _fake_turn_script(prompts, script, signal_writer=None):
"""返回 host.run_task_turn 的桩:按 script 顺序回回合。
script 每项是 (status, text, usage);signal_writer 若给,
在第 signal_writer[0] 轮执行时先写信号再回回合(模拟
本轮进行中提交新 goal,中途不打断本轮)。
"""
state = {"n": 0}
def fake(server, st, task, prompt, model, effort, sandbox,
timeout=None, interrupt_file=None):
prompts.append(prompt)
idx = state["n"]
state["n"] += 1
if signal_writer is not None and idx == signal_writer[0]:
signal_writer[1]()
status, text, usage = script[idx]
return {"status": status, "text": text, "usage": usage}
return fake
class HostLoopTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.state_path = os.path.join(self.tmp.name, "host-state.json")
self.state = {"tasks": {}}
def tearDown(self):
self.tmp.cleanup()
def _task(self, name="codex-abc123"):
task = {"name": name, "cwd": self.tmp.name, "thread_id": "th1",
"turns": []}
self.state["tasks"][name] = task
return task
def _write_signal(self, version, goal):
glu.write_goal_update_signal(
self.state_path, "codex-abc123", version, goal, "T1")
def test_mid_turn_update_applies_at_next_boundary(self):
task = self._task()
prompts = []
script = [
("completed", "did old work",
{"input_tokens": 10, "output_tokens": 1, "total_tokens": 11,
"cached_input_tokens": 0, "cache_write_input_tokens": 0,
"reasoning_output_tokens": 0, "completions": 1}),
("completed", "目标已达成",
{"input_tokens": 20, "output_tokens": 1, "total_tokens": 21,
"cached_input_tokens": 0, "cache_write_input_tokens": 0,
"reasoning_output_tokens": 0, "completions": 1}),
]
fake = _fake_turn_script(
prompts, script,
signal_writer=(0, lambda: self._write_signal(1, "new goal")))
with mock.patch.object(host_mod, "run_task_turn", fake):
result = host_mod.run_goal_loop(
SimpleNamespace(), self.state, self.state_path, task,
"old goal", None, 0, None, None, None, cfg=None)
self.assertTrue(result["ok"])
self.assertEqual(result["rounds_done"], 2)
# 第一轮提示仍是旧 goal(中途不打断),第二轮才是新 goal。
self.assertIn("old goal", prompts[0])
self.assertNotIn("new goal", prompts[0])
self.assertIn("new goal", prompts[1])
self.assertIn("old goal", prompts[1])
self.assertIn("作废", prompts[1])
# 用量累加、不重置;state 检查点记新 goal 与已应用版本。
self.assertEqual(result["usage"]["input_tokens"], 30)
saved = json.load(open(self.state_path, encoding="utf-8"))
entry = saved["tasks"]["codex-abc123"]
self.assertEqual(entry["goal"], "new goal")
self.assertEqual(entry["applied_goal_version"], 1)
self.assertEqual(entry["goal_rounds_done"], 2)
def test_old_declaration_void_when_goal_changes_mid_turn(self):
task = self._task()
prompts = []
script = [
("completed", "做完了\n目标已达成",
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2,
"cached_input_tokens": 0, "cache_write_input_tokens": 0,
"reasoning_output_tokens": 0, "completions": 1}),
("completed", "新目标做完了\n目标已达成",
{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2,
"cached_input_tokens": 0, "cache_write_input_tokens": 0,
"reasoning_output_tokens": 0, "completions": 1}),
]
fake = _fake_turn_script(
prompts, script,
signal_writer=(0, lambda: self._write_signal(1, "new goal")))
with mock.patch.object(host_mod, "run_task_turn", fake):
result = host_mod.run_goal_loop(
SimpleNamespace(), self.state, self.state_path, task,
"old goal", None, 0, None, None, None, cfg=None)
# 旧 goal 的宣告不能结束新 goal 的循环,必须重新宣告。
self.assertTrue(result["ok"])
self.assertEqual(result["rounds_done"], 2)
self.assertIn("new goal", prompts[1])
def test_failed_counter_not_reset_by_goal_change(self):
task = self._task()
prompts = []
script = [
("failed", "boom 1", None),
("failed", "boom 2", None),
("failed", "boom 3", None),
]
fake = _fake_turn_script(
prompts, script,
signal_writer=(1, lambda: self._write_signal(1, "new goal")))
with mock.patch.object(host_mod, "run_task_turn", fake):
result = host_mod.run_goal_loop(
SimpleNamespace(), self.state, self.state_path, task,
"old goal", None, 0, None, None, None, cfg=None)
# 改 goal 不能绕过连续 3 轮失败护栏:第 3 轮失败仍中断。
# rounds_done 是既有语义:连续失败中断在检查点落盘前
# 返回,所以第 3 轮失败不记入 rounds_done(停在 2),
# 本批未改该语义;关键证据是第 3 轮确实跑了且已用新 goal。
self.assertTrue(result["interrupted"])
self.assertEqual(len(prompts), 3)
self.assertEqual(result["rounds_done"], 2)
# 第三轮提示已是新 goal(第二轮结束后应用)。
self.assertIn("new goal", prompts[2])
def test_verify_loop_ignores_signal(self):
task = self._task()
self._write_signal(1, "new goal")
prompts = []
script = [("completed", "done", None)]
fake = _fake_turn_script(prompts, script)
with mock.patch.object(host_mod, "run_task_turn", fake), \
mock.patch.object(
host_mod, "run_gated_verify",
lambda *a, **k: (0, "ok")):
result = host_mod.run_goal_loop(
SimpleNamespace(), self.state, self.state_path, task,
"old goal", "make check", 3, None, None, None, cfg=None)
self.assertTrue(result["ok"])
self.assertNotIn("目标已更新", prompts[0])
saved = json.load(open(self.state_path, encoding="utf-8"))
entry = saved["tasks"]["codex-abc123"]
self.assertEqual(entry["goal"], "old goal")
self.assertEqual(entry["applied_goal_version"], 0)
def test_interrupted_resume_carries_new_goal_without_reset(self):
# 断点续跑:state 里轮次已到 2,认领钩子写好的信号在
# 第一个续跑轮次(第 3 轮)边界应用,轮次不回第 1 轮。
task = self._task()
task["goal"] = "old goal"
task["goal_rounds_done"] = 2
task["applied_goal_version"] = 0
task["status"] = "interrupted"
host_mod.save_state(self.state_path, self.state)
self._write_signal(1, "new goal")
prompts = []
script = [
("completed", "继续做", None),
("completed", "新目标做完了\n目标已达成", None),
]
fake = _fake_turn_script(prompts, script)
with mock.patch.object(host_mod, "run_task_turn", fake):
result = host_mod.run_goal_loop(
SimpleNamespace(), self.state, self.state_path, task,
"old goal", None, 0, None, None, None, cfg=None,
start_round=3, last_verify="", last_turn_status=None,
last_turn_text="")
self.assertTrue(result["ok"])
self.assertEqual(result["rounds_done"], 4)
self.assertIn("new goal", prompts[0])
self.assertIn("旧目标", prompts[0])
if __name__ == "__main__":
unittest.main()