feat: 旧任务继续会话——终端任务带新消息 + 全部选项同一会话续跑
server:POST /tasks/{id}/continue(终端非 merged 任务重排队,
resume_message 列存本轮消息,不传选项沿用原值,plan/goal 冲突
与新建一致,goal_mode=1 时转新 goal 语义;CAS 防并发改状态,
审计 task_continued);tasks.resume_message 三处迁移 + PG 建表。
worker:认领后本轮 prompt 换成续跑消息(只换内存副本),
goal 续跑走 goal-resume 而不是 goal,跑完清 resume_message
(进程被杀时不清,下次认领重跑同一条消息)。
host:goal-resume --goal-text(旧任务接续 goal 模式:跳过状态
与记录守卫,旧线程上从第 1 轮开新目标循环,新语义不限轮次;
无线程时拒绝)。
sandbox:Local/Docker goal_resume_task + popen goal-resume kind;
附带 popen goal 命令改成新语义(无验收不传 --verify,轮次 0
原样传,此前 goal 任务到本地 worker 会直接报错)。
console:详情页终端非 merged 任务出继续会话表单(消息 +
模型/强度/权限/计划/goal,模型下拉复用中转列表)。
测试:新增 test_task_continue.py 11 个 + test_host_continue.py 2 个。
This commit is contained in:
1 parent
cf293914d9
commit
3a4997d346
9 files changed
+823
-20
No files matched your search
@@ -0,0 +1,315 @@
|
||||
"""旧任务继续会话单测:终端任务带新消息 + 全部选项重排队,
|
||||
worker 在同一会话线程上再跑一轮(不是新任务、不是分支)。
|
||||
|
||||
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
|
||||
SQLite;worker/sandbox 侧注入假 runner 录命令。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.sandbox import DockerExecutor, LocalExecutor
|
||||
from cloud.server import create_server, init_db
|
||||
from cloud.worker import Worker
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.sandbox import DockerExecutor, LocalExecutor
|
||||
from cloud.server import create_server, init_db
|
||||
from cloud.worker import Worker
|
||||
|
||||
|
||||
class ServerContinueTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.other_id, self.other_key = self.auth.create_user("other")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if key:
|
||||
hdrs["X-API-Key"] = key
|
||||
if body is not None:
|
||||
payload = json.dumps(body)
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read()
|
||||
conn.close()
|
||||
try:
|
||||
return resp.status, json.loads(raw.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return resp.status, {}
|
||||
|
||||
def _task_in_status(self, status, **extra):
|
||||
_s, task = self._req(
|
||||
"POST", "/tasks",
|
||||
{"project_id": self.project["id"], "prompt": "original"},
|
||||
self.owner_key)
|
||||
self.assertEqual(_s, 201, task)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
sets = ["status = ?"]
|
||||
vals = [status]
|
||||
for k, v in extra.items():
|
||||
sets.append("%s = ?" % k)
|
||||
vals.append(v)
|
||||
vals.append(task["id"])
|
||||
conn.execute("UPDATE tasks SET %s WHERE id = ?"
|
||||
% ", ".join(sets), vals)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return task["id"]
|
||||
|
||||
def _continue(self, task_id, body, key=None):
|
||||
return self._req(
|
||||
"POST", "/tasks/%s/continue" % task_id, body,
|
||||
key or self.owner_key)
|
||||
|
||||
def test_continue_completed_with_options(self):
|
||||
tid = self._task_in_status("completed")
|
||||
status, task = self._continue(tid, {
|
||||
"message": "follow up please", "model": "gpt-6-sol",
|
||||
"effort": "high", "approval": "auto", "plan": 1})
|
||||
self.assertEqual(status, 200, task)
|
||||
self.assertEqual(task["status"], "queued")
|
||||
self.assertEqual(task["resume_message"], "follow up please")
|
||||
self.assertEqual(task["model"], "gpt-6-sol")
|
||||
self.assertEqual(task["effort"], "high")
|
||||
self.assertEqual(task["approval"], "auto")
|
||||
self.assertEqual(task["plan"], 1)
|
||||
self.assertEqual(task["prompt"], "original")
|
||||
|
||||
def test_continue_keeps_options_when_absent(self):
|
||||
tid = self._task_in_status("failed", model="gpt-5.6-terra")
|
||||
status, task = self._continue(tid, {"message": "again"})
|
||||
self.assertEqual(status, 200, task)
|
||||
self.assertEqual(task["model"], "gpt-5.6-terra")
|
||||
self.assertEqual(task["resume_message"], "again")
|
||||
|
||||
def test_continue_goal_mode_converts_to_new_semantics(self):
|
||||
tid = self._task_in_status(
|
||||
"completed", goal="old goal", verify_command="true",
|
||||
goal_max_rounds=3)
|
||||
status, task = self._continue(
|
||||
tid, {"message": "new goal", "goal_mode": 1})
|
||||
self.assertEqual(status, 200, task)
|
||||
self.assertEqual(task["goal_mode"], 1)
|
||||
self.assertEqual(task["goal"], "new goal")
|
||||
self.assertIsNone(task["verify_command"])
|
||||
self.assertEqual(task["goal_max_rounds"], 0)
|
||||
|
||||
def test_continue_rejects_non_terminal_and_merged(self):
|
||||
for status in ("queued", "running"):
|
||||
tid = self._task_in_status(status)
|
||||
code, _body = self._continue(tid, {"message": "hi"})
|
||||
self.assertEqual(code, 409, status)
|
||||
tid = self._task_in_status("merged")
|
||||
code, _body = self._continue(tid, {"message": "hi"})
|
||||
self.assertEqual(code, 409)
|
||||
code, _body = self._continue("no-such-task", {"message": "hi"})
|
||||
self.assertEqual(code, 404)
|
||||
|
||||
def test_continue_rejects_bad_input_and_conflicts(self):
|
||||
tid = self._task_in_status("completed")
|
||||
for body, code in [
|
||||
({"message": ""}, 400),
|
||||
({"message": "x" * 20001}, 400),
|
||||
({"message": "hi", "effort": "ultra"}, 400),
|
||||
({"message": "hi", "approval": "never"}, 400),
|
||||
({"message": "hi", "plan": 1, "approval": "full"}, 400),
|
||||
({"message": "hi", "plan": 1, "goal_mode": 1}, 400)]:
|
||||
got, _body = self._continue(tid, body)
|
||||
self.assertEqual(got, code, body)
|
||||
|
||||
def test_continue_forbidden_for_non_member(self):
|
||||
tid = self._task_in_status("completed")
|
||||
code, _body = self._continue(tid, {"message": "hi"},
|
||||
key=self.other_key)
|
||||
self.assertEqual(code, 403)
|
||||
|
||||
|
||||
class WorkerContinueTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
init_db(self.db_path)
|
||||
self.env = {"XCM_API_KEY": "test-placeholder"}
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _insert(self, task_id, **extra):
|
||||
now = time.time()
|
||||
cols = ["id", "project_id", "prompt", "status",
|
||||
"idempotency_key", "depends_on", "priority",
|
||||
"created_by", "created_at", "updated_at",
|
||||
"last_heartbeat"]
|
||||
vals = [task_id, "p1", "original", "queued", None, "[]", 0,
|
||||
"tester", now, now, None]
|
||||
for k, v in extra.items():
|
||||
cols.append(k)
|
||||
vals.append(v)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(%s) VALUES(%s)"
|
||||
% (", ".join(cols), ", ".join("?" * len(cols))), vals)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _row(self, task_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT status, resume_message FROM tasks WHERE id = ?",
|
||||
(task_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return row
|
||||
|
||||
def test_resume_normal_runs_new_message_on_same_thread(self):
|
||||
self._insert("t1", resume_message="follow up please")
|
||||
seen = []
|
||||
|
||||
def fake(cmd):
|
||||
seen.append(list(cmd))
|
||||
if "dispatch" in cmd:
|
||||
# host 任务已存在:dispatch 失败,worker 回落到 run。
|
||||
return SimpleNamespace(returncode=1, stdout="任务已存在",
|
||||
stderr="")
|
||||
if "run" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout="ok",
|
||||
stderr="")
|
||||
raise AssertionError("不应有第三种命令: %s" % cmd)
|
||||
|
||||
worker = Worker(self.db_path, "/fake/host.py", runner=fake,
|
||||
env=self.env)
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "completed")
|
||||
run_cmds = [c for c in seen if "run" in c]
|
||||
self.assertTrue(run_cmds)
|
||||
self.assertEqual(run_cmds[0][run_cmds[0].index("--prompt") + 1],
|
||||
"follow up please")
|
||||
status, resume = self._row("t1")
|
||||
self.assertEqual(status, "completed")
|
||||
self.assertIsNone(resume)
|
||||
|
||||
def test_resume_goal_routes_to_goal_resume(self):
|
||||
self._insert("t2", goal_mode=1, goal="old goal",
|
||||
goal_max_rounds=0,
|
||||
resume_message="new goal")
|
||||
seen = []
|
||||
|
||||
def fake(cmd):
|
||||
seen.append(list(cmd))
|
||||
if "goal-resume" in cmd:
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="round 1: turn=completed verify_rc=- "
|
||||
"verify_out=\ngoal met after rounds\n",
|
||||
stderr="")
|
||||
raise AssertionError("goal 续跑不应走 goal/dispatch: %s" % cmd)
|
||||
|
||||
worker = Worker(self.db_path, "/fake/host.py", runner=fake,
|
||||
env=self.env)
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertTrue(any("goal-resume" in c for c in seen))
|
||||
cmd = [c for c in seen if "goal-resume" in c][0]
|
||||
self.assertEqual(cmd[cmd.index("--goal-text") + 1], "new goal")
|
||||
self.assertEqual(cmd[cmd.index("--max-rounds") + 1], "0")
|
||||
status, resume = self._row("t2")
|
||||
self.assertEqual(status, "completed")
|
||||
self.assertIsNone(resume)
|
||||
|
||||
|
||||
class SandboxContinueTestCase(unittest.TestCase):
|
||||
def _recording(self, calls):
|
||||
def fake(cmd):
|
||||
calls.append(list(cmd))
|
||||
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
||||
return fake
|
||||
|
||||
def test_local_goal_resume_command(self):
|
||||
calls = []
|
||||
ex = LocalExecutor("/fake/host.py",
|
||||
runner=self._recording(calls))
|
||||
ex.goal_resume_task({"id": "t1", "prompt": "new goal"},
|
||||
"new goal")
|
||||
self.assertIn("goal-resume", calls[0])
|
||||
self.assertEqual(calls[0][calls[0].index("--goal-text") + 1],
|
||||
"new goal")
|
||||
self.assertEqual(calls[0][calls[0].index("--max-rounds") + 1],
|
||||
"0")
|
||||
|
||||
def test_docker_goal_resume_command(self):
|
||||
from unittest import mock
|
||||
calls = []
|
||||
ex = DockerExecutor(
|
||||
"/fake/host.py", runner=self._recording(calls),
|
||||
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
|
||||
with mock.patch("cloud.sandbox.shutil.which",
|
||||
return_value="/usr/bin/docker"):
|
||||
ex.goal_resume_task({"id": "t1", "prompt": "new goal"},
|
||||
"new goal")
|
||||
self.assertIn("goal-resume", calls[0])
|
||||
self.assertIn("new goal", calls[0])
|
||||
|
||||
def test_popen_goal_resume_command(self):
|
||||
import cloud.sandbox as sandbox_mod
|
||||
calls = []
|
||||
|
||||
class FakePopen(object):
|
||||
def __init__(self, cmd, **kwargs):
|
||||
calls.append(list(cmd))
|
||||
|
||||
orig = sandbox_mod.subprocess.Popen
|
||||
sandbox_mod.subprocess.Popen = FakePopen
|
||||
try:
|
||||
ex = LocalExecutor("/fake/host.py")
|
||||
ex.popen_task({"id": "t1", "prompt": "new goal"},
|
||||
kind="goal-resume")
|
||||
finally:
|
||||
sandbox_mod.subprocess.Popen = orig
|
||||
self.assertIn("goal-resume", calls[0])
|
||||
self.assertEqual(calls[0][calls[0].index("--goal-text") + 1],
|
||||
"new goal")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user