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.
510 lines
21 KiB
Python
510 lines
21 KiB
Python
"""运行中改 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()
|