diff --git a/cloud/README.md b/cloud/README.md
index c48567b..5f3f96e 100644
--- a/cloud/README.md
+++ b/cloud/README.md
@@ -495,6 +495,24 @@ verify_command,goal 新语义(无验收)任务到本地 worker 会直接
报错,本批顺手修成与同步 goal_task 一致(无验收不传 --verify,
轮次 0 原样传)。
+## 旧任务继续会话(终端任务接回来再跑一轮)
+
+详情页对终端任务(completed/failed/interrupted/merge-failed)
+提供“继续会话”:填本轮消息 + 模型/强度/权限/计划/goal 全可选
+(不传的沿用任务原值),`POST /tasks/{id}/continue` 把任务重
+新排队,worker 认领后在同一会话线程上再跑一轮——不是新任务
+(原 prompt 不动,只换本轮输入),也不是分支(不建新线程)。
+新消息存在 tasks.resume_message 列,worker 跑完清掉;进程中途
+被杀时清不到,下次认领重跑同一条消息,不静默丢消息。
+goal 模式的继续:server 把任务转成新 goal 语义(goal=新消息、
+验收清空、轮次 0),worker 走 host.py `goal-resume --goal-text`
+在旧线程上开新目标循环(不限轮次,模型宣告完成才结束);
+普通任务的继续走 dispatch 撞已存在 → run 续跑(与重试同款)。
+不适用:进行中任务请用后续消息(queued/running 各 409),已
+合并任务 worktree 已删、请分支(merged 409);冲突与新建一致
+(plan + approval=full、plan + goal 各 400)。
+
+
## 测试
```bash
diff --git a/cloud/console.html b/cloud/console.html
index f8ca93f..99db94c 100644
--- a/cloud/console.html
+++ b/cloud/console.html
@@ -215,6 +215,30 @@
+
实时链路
点任务行查看详情后,这里显示过程链路
@@ -280,10 +304,12 @@ async function loadRelayModels() {
try {
var d = await api("/relay/models", "GET");
var models = d.models || [];
- sel.innerHTML = "" +
- models.map(function (m) {
- return "";
- }).join("");
+ var opts = models.map(function (m) {
+ return "";
+ }).join("");
+ sel.innerHTML = "" + opts;
+ var csel = $("#continueModel");
+ if (csel) csel.innerHTML = "" + opts;
$("#modelCustom").style.display = "none";
} catch (e) {
$("#modelCustom").style.display = "inline";
@@ -529,6 +555,8 @@ async function showDetail(taskId) {
$("#forkMsgSpan").textContent = "";
$("#followupMsg").textContent = "";
$("#followupForm").style.display = ["queued", "running"].indexOf(t.status) >= 0 ? "block" : "none";
+ $("#continueMsgSpan").textContent = "";
+ $("#continueForm").style.display = (TRACE_TERMINAL.indexOf(t.status) >= 0 && t.status !== "merged") ? "block" : "none";
startTrace(taskId, t.status);
loadFollowups(taskId);
} catch (e) { $("#detail").innerHTML = "加载失败:" + esc(e.message) + ""; }
@@ -723,6 +751,30 @@ $("#btnForkConfirm").onclick = async function () {
loadTasks();
} catch (e) { $("#forkMsgSpan").innerHTML = "分支失败:" + esc(e.message) + ""; }
};
+$("#btnContinueConfirm").onclick = async function () {
+ if (!currentTask) return;
+ var msg = $("#continueText").value.trim();
+ if (!msg) { $("#continueMsgSpan").textContent = "本轮消息必填"; return; }
+ var body = { message: msg };
+ var model = $("#continueModel").value;
+ if (model) body.model = model;
+ var eff = $("#continueEffort").value;
+ if (eff) body.effort = eff;
+ var ap = $("#continueApproval").value;
+ if (ap) {
+ if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认继续吗?")) return;
+ body.approval = ap;
+ }
+ if ($("#continuePlan").checked) body.plan = 1;
+ if ($("#continueGoal").checked) body.goal_mode = 1;
+ try {
+ var t = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/continue", "POST", body);
+ $("#continueMsgSpan").innerHTML = "已重新排队,同一会话继续";
+ $("#continueText").value = "";
+ showDetail(currentTask.id);
+ loadTasks();
+ } catch (e) { $("#continueMsgSpan").innerHTML = "继续失败:" + esc(e.message) + ""; }
+};
$("#btnQueueFollowup").onclick = function () { postFollowup("queued"); };
$("#btnDirectFollowup").onclick = function () { postFollowup("direct"); };
$("#btnCost").onclick = async function () {
diff --git a/cloud/db.py b/cloud/db.py
index ae18cfd..c7afcbe 100644
--- a/cloud/db.py
+++ b/cloud/db.py
@@ -838,6 +838,7 @@ POSTGRES_SCHEMA = (
"plan INTEGER, "
"forked_from TEXT, "
"environment_id TEXT, "
+ "resume_message TEXT, "
"UNIQUE(project_id, idempotency_key))",
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
@@ -965,7 +966,8 @@ def init_postgres_schema(conn):
("pr_number", "INTEGER"),
("worktree_host", "TEXT"),
("goal_mode", "INTEGER"),
- ("environment_id", "TEXT")):
+ ("environment_id", "TEXT"),
+ ("resume_message", "TEXT")):
if _name not in tcols:
conn.execute(
"ALTER TABLE tasks ADD COLUMN %s %s" % (_name, _type))
diff --git a/cloud/sandbox.py b/cloud/sandbox.py
index e5bdb69..8de55f3 100644
--- a/cloud/sandbox.py
+++ b/cloud/sandbox.py
@@ -386,6 +386,27 @@ class LocalExecutor(Executor):
return self._goal_result_from_runner(
task["id"], self.runner(cmd))
+ def goal_resume_task(self, task, goal_text) -> dict:
+ """调 host.py goal-resume --goal-text:在旧线程上开新目标
+ 循环(旧任务接续 goal 模式,云端 /continue 带 goal_mode=1
+ 时走这里)。命令:host.py goal-resume --task
+ --goal-text <新目标> --max-rounds 0(新语义不限轮次)。
+ 不传 --repo:goal-resume 操作已有 host 任务,不新建
+ worktree。结论解析与 goal_task 同款。
+ """
+ if not isinstance(task, dict) or task.get("id") is None:
+ raise ValueError("任务缺少 id")
+ if not goal_text:
+ raise ValueError("goal-resume 缺少目标文本")
+ cmd = self._base_command(model=task.get("model"),
+ effort=task.get("effort"),
+ approval=task.get("approval"))
+ cmd += ["goal-resume", "--task", self._host_name(task),
+ "--goal-text", goal_text,
+ "--max-rounds", "0"]
+ return self._goal_result_from_runner(
+ task["id"], self.runner(cmd))
+
def _goal_result_from_runner(self, task_id, result):
"""把 host.py goal 的 runner 返回值解析成 goal 结果 dict。
@@ -437,12 +458,14 @@ class LocalExecutor(Executor):
start_new_session=True 让子进程自成进程组,worker 取消时
killpg 才能连同子孙一起杀掉。注意:只有默认 runner(真实
subprocess)时才用本方法;单测注入的假 runner 走同步路径,
- 不走这里。kind=goal 时建 goal 命令(结论解析由 worker 的
- _execute_via_process 按 kind 分发到 _goal_result_from_runner)。
- goal 语义与同步 goal_task 一致:新形态(goal_mode)无验收
- 命令时 --verify 不传、--max-rounds 传 0(0 不能用 or 兜底
- 成 3);旧形态仍传验收命令与轮次。seed_dir 只对 dispatch/
- goal 追加(run 续跑不传,文件已在 worktree 里)。
+ 不走这里。kind=goal 时建 goal 命令、kind=goal-resume 时建
+ goal-resume 命令(旧任务接续 goal 模式,目标文本取本轮
+ prompt;结论解析由 worker 的 _execute_via_process 按 kind
+ 分发到 _goal_result_from_runner)。goal 语义与同步 goal_task
+ 一致:新形态(goal_mode)无验收命令时 --verify 不传、
+ --max-rounds 传 0(0 不能用 or 兜底成 3);旧形态仍传验收
+ 命令与轮次。seed_dir 只对 dispatch/goal 追加(run 续跑与
+ goal-resume 不传,文件已在 worktree 里)。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -474,6 +497,13 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
+ elif kind == "goal-resume":
+ # 旧任务接续 goal:host 侧用 --goal-text 在旧线程上开
+ # 新目标循环(新语义,不限轮次);task["prompt"] 在续跑
+ # 时已被 worker 换成续跑消息。
+ cmd += ["goal-resume", "--task", self._host_name(task),
+ "--goal-text", task["prompt"],
+ "--max-rounds", "0"]
else:
cmd += ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
@@ -1041,6 +1071,80 @@ class DockerExecutor(Executor):
"usage": _parse_goal_usage(stdout_full),
}
+ def goal_resume_task(self, task, goal_text, timeout=None,
+ extra_mounts=None, network=None,
+ extra_env=None) -> dict:
+ """容器内调 host.py goal-resume --goal-text:在旧线程上开
+ 新目标循环(旧任务接续 goal 模式)。解析与 goal_task 同款
+ (完整 stdout 结论、存库截尾),但没有“已存在”分支:
+ goal-resume 本来就是操作已有 host 任务,不存在 dispatch
+ 撞名问题;returncode 非 0 或无结论一律 failed。
+ """
+ if not isinstance(task, dict) or task.get("id") is None:
+ raise ValueError("任务缺少 id")
+ if not goal_text:
+ raise ValueError("goal-resume 缺少目标文本")
+ inner = self._inner_host_command(
+ "goal-resume", "--task", self._host_name(task),
+ "--goal-text", goal_text, "--max-rounds", "0",
+ model=task.get("model"), effort=task.get("effort"),
+ approval=task.get("approval"))
+ cmd = self.build_run_command(task, inner,
+ extra_mounts=extra_mounts,
+ network=network,
+ extra_env=extra_env)
+ try:
+ result = self._call_runner(cmd, timeout=timeout)
+ except Exception as exc:
+ if exc.__class__.__name__ == "TimeoutExpired":
+ self.kill_container(task)
+ return {
+ "executor": "docker", "task_id": task["id"],
+ "status": "failed", "stdout": "docker run timeout",
+ "returncode": None, "host_status": None, "stderr": "",
+ "container": self.container_name(task),
+ "goal_met": None, "goal_error": "docker run timeout",
+ }
+ return {
+ "executor": "docker", "task_id": task["id"],
+ "status": "failed",
+ "stdout": _truncate_stdout(str(exc)),
+ "returncode": None, "host_status": None, "stderr": "",
+ "container": self.container_name(task),
+ "goal_met": None, "goal_error": str(exc)[:500],
+ }
+ returncode = getattr(result, "returncode", 1)
+ stdout_full = getattr(result, "stdout", "") or ""
+ stderr = getattr(result, "stderr", "") or ""
+ met, goal_error = _parse_goal_output(stdout_full)
+ if returncode != 0:
+ status = "failed"
+ if met is None:
+ goal_error = ((stderr.strip()[-500:] if stderr.strip()
+ else stdout_full.strip()[-500:])
+ or "host goal-resume failed")
+ elif met is True:
+ status = "completed"
+ elif met is False:
+ status = "failed"
+ else:
+ status = "failed"
+ goal_error = goal_error or "host goal-resume 输出无结论"
+ return {
+ "executor": "docker",
+ "task_id": task["id"],
+ "status": status,
+ "stdout": _goal_stdout_tail(stdout_full),
+ "stderr": stderr,
+ "returncode": returncode,
+ "host_status": ("goal-met" if met is True else
+ "goal-unmet" if met is False else None),
+ "container": self.container_name(task),
+ "goal_met": met,
+ "goal_error": goal_error,
+ "usage": _parse_goal_usage(stdout_full),
+ }
+
def network_gateway(self, network_name):
"""某 Docker 网络的网关 IP(宿主在该网桥上的地址)。
diff --git a/cloud/server.py b/cloud/server.py
index ec5101f..21a1a99 100644
--- a/cloud/server.py
+++ b/cloud/server.py
@@ -466,6 +466,10 @@ def migrate_tasks_table(conn):
# 项目仓库路径);环境本身是项目仓库的一个命名基准检出
# (分支 + 执行器),任务 worktree 从它建,不改任务隔离。
"environment_id": "TEXT",
+ # 续跑消息:旧任务继续会话时本轮要说的话(NULL = 没有待
+ # 续跑消息)。worker 认领后把 prompt 换成它跑一轮,跑完
+ # 清掉;任务详情里照常显示原 prompt,续跑消息只作本轮输入。
+ "resume_message": "TEXT",
}
for name, coltype in new_cols.items():
if name not in cols:
@@ -777,6 +781,7 @@ def _task_to_dict(row, attachments=None):
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
"goal_mode": _row_get(row, "goal_mode") or 0,
"attachments": list(attachments or []),
+ "resume_message": _row_get(row, "resume_message"),
"worktree_host": _row_get(row, "worktree_host"),
"model": _row_get(row, "model"),
"effort": _row_get(row, "effort"),
@@ -1132,6 +1137,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._list_followups(user_id, rest[: -len("/followups")])
elif rest.endswith("/fork") and method == "POST":
self._fork_task(user_id, rest[: -len("/fork")])
+ elif rest.endswith("/continue") and method == "POST":
+ self._continue_task(user_id, rest[: -len("/continue")])
else:
self._send_json(404, {"error": "not found"})
else:
@@ -2615,6 +2622,157 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.audit(user_id, task["project_id"], "merge_task", task_id)
self._send_json(200, task)
+ def _continue_task(self, user_id, task_id):
+ """旧任务接回来继续会话:终端任务(completed/failed/
+ interrupted/merge-failed)带一条新消息 + 全部选项重排队,
+ worker 认领后在同一会话线程上再跑一轮(不是新任务、不
+ 是分支)。新消息存在 resume_message 列,worker 跑完清掉;
+ 原 prompt 不动,详情里仍显示原 prompt。
+
+ 规则:任务必须存在(404)、调用方须有 create_task 权限
+ (与后续消息同级:这本质上是“给旧会话发消息”)、任务
+ 必须是终端且非 merged(queued/running 请用后续消息,
+ merged 的 worktree 已删、请分支,各 409)。选项与新建
+ 任务同款校验:model(空串 = 跟随配置)、effort、approval、
+ plan、goal_mode;不传的选项沿用任务原值。冲突与新建
+ 一致:plan + approval=full、plan + goal_mode 各 400。
+ goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
+ verify_command 清空、轮次 0),worker 走 goal-resume
+ 在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用
+ 原语义续跑。状态回 queued 时上一轮 result/error 清空
+ (与重试一致,否则排队中的任务还挂着旧结果)。
+ 并发下用 CAS(WHERE status IN 终端四态),没改到行
+ 说明状态已变,返回 409 而不是假装成功。写审计
+ task_continued。
+ """
+ row = self._fetch_task(task_id)
+ if row is None:
+ self._send_json(404, {"error": "task not found"})
+ return
+ task = _task_to_dict(row)
+ with _auth_store(self.server.db_path) as store:
+ if not store.check_permission(
+ user_id, task["project_id"], "create_task"):
+ self._send_json(403, {"error": "forbidden"})
+ return
+ if task["status"] in ("queued", "running"):
+ self._send_json(409, {
+ "error": "进行中任务请用后续消息继续",
+ "status": task["status"],
+ })
+ return
+ if task["status"] == "merged":
+ self._send_json(409, {
+ "error": "已合并任务不能继续(worktree 已删),请分支",
+ "status": task["status"],
+ })
+ return
+ if task["status"] not in TERMINAL_STATUSES:
+ self._send_json(409, {
+ "error": "only terminal tasks can be continued",
+ "status": task["status"],
+ })
+ return
+ body, err = self._read_json()
+ if err:
+ self._send_json(400, {"error": err})
+ return
+ message = body.get("message")
+ if not isinstance(message, str) or not message.strip():
+ self._send_json(400, {"error": "message 必填"})
+ return
+ message = message.strip()
+ if len(message) > 20000:
+ self._send_json(400, {"error": "message too long"})
+ return
+ model = body.get("model")
+ if model is not None:
+ if not isinstance(model, str):
+ self._send_json(400, {
+ "error": "model must be a string"})
+ return
+ model = model.strip() or None
+ effort = body.get("effort")
+ if effort is not None and effort not in (
+ "low", "medium", "high", "xhigh", "max"):
+ self._send_json(400, {
+ "error": "effort must be low, medium, high, xhigh or max"})
+ return
+ approval = body.get("approval")
+ if approval is not None and approval not in (
+ "ask", "auto", "full"):
+ self._send_json(400, {
+ "error": "approval must be ask, auto or full"})
+ return
+ plan = None
+ if "plan" in body:
+ plan = 1 if body["plan"] in (True, 1) else 0
+ goal_mode = None
+ if "goal_mode" in body:
+ goal_mode = 1 if body["goal_mode"] in (True, 1) else 0
+ plan_eff = plan if plan is not None else (task["plan"] or 0)
+ goal_eff = goal_mode if goal_mode is not None \
+ else (task["goal_mode"] or 0)
+ approval_eff = approval if approval is not None \
+ else task["approval"]
+ if plan_eff and approval_eff == "full":
+ self._send_json(400, {
+ "error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
+ return
+ if plan_eff and goal_eff:
+ self._send_json(400, {
+ "error": "plan 模式与 goal 模式冲突"})
+ return
+ sets = ["status = 'queued'", "resume_message = ?",
+ "result_text = NULL", "error_text = NULL",
+ "actual_model = NULL", "updated_at = ?"]
+ vals = [message, time.time()]
+ if model is not None or "model" in body:
+ sets.append("model = ?")
+ vals.append(model)
+ if effort is not None:
+ sets.append("effort = ?")
+ vals.append(effort)
+ if approval is not None:
+ sets.append("approval = ?")
+ vals.append(approval)
+ if plan is not None:
+ sets.append("plan = ?")
+ vals.append(plan)
+ if goal_mode is not None:
+ sets.append("goal_mode = ?")
+ vals.append(goal_mode)
+ if goal_mode == 1:
+ sets.append("goal = ?")
+ vals.append(message)
+ sets.append("verify_command = NULL")
+ sets.append("goal_max_rounds = 0")
+ vals.append(task_id)
+ conn = self._db()
+ try:
+ cur = conn.execute(
+ "UPDATE tasks SET %s WHERE id = ? AND status IN"
+ " ('completed', 'failed', 'interrupted', 'merge-failed')"
+ % ", ".join(sets),
+ tuple(vals),
+ )
+ conn.commit()
+ if cur.rowcount != 1:
+ self._send_json(409, {
+ "error": "任务状态已变化,请刷新后重试",
+ "status": task["status"],
+ })
+ return
+ new_row = conn.execute(
+ "SELECT * FROM tasks WHERE id = ?", (task_id,)
+ ).fetchone()
+ finally:
+ conn.close()
+ with _auth_store(self.server.db_path) as store:
+ store.audit(user_id, task["project_id"], "task_continued",
+ task_id)
+ self._send_json(200, _task_to_dict(new_row))
+
def _retry_task(self, user_id, task_id):
"""把终态失败任务重新排队:只改状态,不直接执行。
diff --git a/cloud/tests/test_task_continue.py b/cloud/tests/test_task_continue.py
new file mode 100644
index 0000000..8b479b2
--- /dev/null
+++ b/cloud/tests/test_task_continue.py
@@ -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()
diff --git a/cloud/worker.py b/cloud/worker.py
index 5530b73..733f598 100644
--- a/cloud/worker.py
+++ b/cloud/worker.py
@@ -247,6 +247,8 @@ def _task_from_row(row):
"forked_from": _col("forked_from"),
# 环境:老行缺列时 None = 不指定环境,走项目仓库路径。
"environment_id": _col("environment_id"),
+ # 续跑消息:老行缺列时 None = 没有待续跑消息。
+ "resume_message": _col("resume_message"),
}
@@ -580,7 +582,8 @@ class Worker:
"approval": "TEXT",
"plan": "INTEGER",
"forked_from": "TEXT",
- "environment_id": "TEXT"}.items():
+ "environment_id": "TEXT",
+ "resume_message": "TEXT"}.items():
if name not in cols:
try:
conn.execute(
@@ -1018,7 +1021,7 @@ class Worker:
"host_status": None}
fake = SimpleNamespace(returncode=proc.returncode,
stdout=out or "", stderr=err or "")
- if kind == "goal" and hasattr(
+ if kind in ("goal", "goal-resume") and hasattr(
self.executor, "_goal_result_from_runner"):
return self.executor._goal_result_from_runner(task_id, fake)
if hasattr(self.executor, "_result_from_runner"):
@@ -1634,8 +1637,14 @@ class Worker:
effective_docker_timeout = self.task_timeout
try:
if self._use_popen():
+ if goal_mode and task.get("_resume"):
+ popen_kind = "goal-resume"
+ elif goal_mode:
+ popen_kind = "goal"
+ else:
+ popen_kind = "dispatch"
result = self._execute_via_process(
- task, kind="goal" if goal_mode else "dispatch",
+ task, kind=popen_kind,
deadline_seconds=self.task_timeout,
seed_host_dir=seed_host_dir)
combined = (result.get("stdout", "") or "") + \
@@ -1663,12 +1672,20 @@ class Worker:
"stderr": ""},
prep_err)
if goal_mode:
- result = self.executor.goal_task(
- task, timeout=effective_docker_timeout,
- extra_mounts=tenant_mounts,
- network=tenant_network,
- extra_env=proxy_env,
- seed_host_dir=seed_host_dir)
+ if task.get("_resume"):
+ result = self.executor.goal_resume_task(
+ task, task["prompt"],
+ timeout=effective_docker_timeout,
+ extra_mounts=tenant_mounts,
+ network=tenant_network,
+ extra_env=proxy_env)
+ else:
+ result = self.executor.goal_task(
+ task, timeout=effective_docker_timeout,
+ extra_mounts=tenant_mounts,
+ network=tenant_network,
+ extra_env=proxy_env,
+ seed_host_dir=seed_host_dir)
else:
result = self.executor.dispatch_task(
task, timeout=effective_docker_timeout,
@@ -1836,6 +1853,15 @@ class Worker:
return {"dispatched": False, "reason": "already_claimed",
"task_id": task_id}
task = self.get_task(task_id) or task
+ # 续跑消息:旧任务继续会话时本轮要说的话——本轮 prompt 换
+ # 成它(只换内存里的副本,原 prompt 不动);_resume 标记
+ # 让 _run_attempt 在 goal 模式下走 goal-resume 而不是 goal
+ # (goal 会撞已存在,只有 goal-resume 能在旧线程上开新循环)。
+ resume_message = task.get("resume_message")
+ if resume_message:
+ task = dict(task)
+ task["prompt"] = resume_message
+ task["_resume"] = True
self._audit(task["project_id"], "worker_claim",
"%s executor=%s" % (task_id, self._executor_name()))
# log_event 只接受 task_id/project_id/status/message 四个
@@ -2153,6 +2179,21 @@ class Worker:
# 补一条 worker_completed/failed 审计误导排查。
final = "interrupted"
self._audit(task["project_id"], "worker_interrupted", task_id)
+ if resume_message:
+ # 本轮已跑完(无论成败):续跑消息已消费,清掉;进程
+ # 中途被杀时清不到,下次认领会重跑同一条消息——可接受
+ # 的续跑语义,不会静默丢消息。清失败不挡结果返回。
+ try:
+ conn = _db_connect(self.db_path)
+ try:
+ conn.execute(
+ "UPDATE tasks SET resume_message = NULL"
+ " WHERE id = ?", (task_id,))
+ conn.commit()
+ finally:
+ conn.close()
+ except Exception:
+ pass
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
diff --git a/host.py b/host.py
index fd4df36..98ece6f 100644
--- a/host.py
+++ b/host.py
@@ -1992,6 +1992,10 @@ def main():
help="验测命令上限(秒);0 表示不限时,默认 600")
p = sub.add_parser("goal-resume")
p.add_argument("--task", required=True)
+ p.add_argument("--goal-text", default=None,
+ help="新目标文本:旧任务接续 goal 模式时用——跳过状态与"
+ "goal 记录守卫,在旧线程上从第 1 轮开新目标循环"
+ "(新语义:无验收、不传 --max-rounds 时不限轮次)")
p.add_argument("--max-rounds", type=int, default=None,
help="不传时沿用原任务的 goal_max_rounds;传了则覆盖总轮次")
p.add_argument("--verify-timeout", type=int, default=600,
@@ -2583,6 +2587,29 @@ def main():
verify_timeout=args.verify_timeout)
elif args.cmd == "goal-resume":
t = state["tasks"][args.task]
+ if args.goal_text:
+ # 旧任务接续 goal 模式(云端 /continue 带 goal_mode=1
+ # 时走这里):旧任务已终局、没有 goal 记录是正常情况,
+ # 不能按断点续跑的守卫拒绝。只要线程还在,就在旧线程
+ # 上从第 1 轮开新目标循环(新语义:无验收、不传轮次
+ # 时不限轮次,模型宣告完成才结束)。
+ if not t.get("thread_id"):
+ print(f"goal-resume 拒绝:{args.task} 没有会话线程,"
+ f"不能接续")
+ return
+ max_rounds = args.max_rounds \
+ if args.max_rounds is not None else 0
+ srv.resume_thread(t["thread_id"])
+ run_goal_loop(srv, state, args.state, t, args.goal_text,
+ None, max_rounds,
+ args.model, args.effort, args.sandbox, cfg=cfg,
+ timeout=args.turn_timeout,
+ verify_timeout=args.verify_timeout,
+ start_round=1,
+ last_verify="",
+ last_turn_status=None,
+ last_turn_text="")
+ return
if t.get("status") not in ("goal-running", "interrupted",
"running"):
print(f"goal-resume 拒绝:{args.task} 当前状态是 "
diff --git a/tests/test_host_continue.py b/tests/test_host_continue.py
new file mode 100644
index 0000000..b13a241
--- /dev/null
+++ b/tests/test_host_continue.py
@@ -0,0 +1,86 @@
+"""旧任务接续 goal 模式:host.py goal-resume --goal-text。
+
+已终局的旧任务没有 goal 记录是正常情况,--goal-text 模式跳过
+状态与记录守卫,在旧线程上从第 1 轮开新目标循环(新语义:
+无验收、不传轮次时不限轮次,模型宣告完成才结束)。
+"""
+
+import json
+import os
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT))
+
+import host
+
+
+class FakeServer(object):
+ def __init__(self, text):
+ self.text = text
+ self.resumed = []
+
+ def initialize(self):
+ pass
+
+ def close(self):
+ pass
+
+ def resume_thread(self, thread_id):
+ self.resumed.append(thread_id)
+
+ def run_turn(self, thread_id, prompt, model=None, effort=None,
+ sandbox=None, timeout=None):
+ return {"status": "completed", "text": self.text,
+ "turn_id": "u1"}
+
+
+class GoalResumeTextTest(unittest.TestCase):
+ def _run_main(self, task, goal_text):
+ with tempfile.TemporaryDirectory() as tmp:
+ state_path = os.path.join(tmp, "host-state.json")
+ cfg_path = os.path.join(tmp, "host-config.json")
+ with open(state_path, "w", encoding="utf-8") as f:
+ json.dump({"tasks": {"g": task}, "automations": []}, f)
+ with open(cfg_path, "w", encoding="utf-8") as f:
+ json.dump({}, f)
+ fake = FakeServer("done\n目标已达成")
+ argv = ["host.py", "--state", state_path,
+ "--config", cfg_path,
+ "goal-resume", "--task", "g"]
+ if goal_text is not None:
+ argv += ["--goal-text", goal_text]
+ with mock.patch.object(sys, "argv", argv):
+ with mock.patch.object(host, "Server",
+ lambda **kw: fake):
+ host.main()
+ with open(state_path, encoding="utf-8") as f:
+ saved = json.load(f)["tasks"]["g"]
+ return saved, fake
+
+ def test_goal_text_init_on_completed_task(self):
+ task = {"name": "g", "thread_id": "x", "cwd": "/tmp",
+ "sandbox": None, "max_retries": 0, "retry_count": 0,
+ "turns": [], "status": "completed"}
+ saved, fake = self._run_main(task, "new goal")
+ self.assertEqual(saved["status"], "goal-met")
+ self.assertEqual(saved["goal"], "new goal")
+ self.assertEqual(saved["goal_rounds_done"], 1)
+ self.assertEqual(fake.resumed, ["x"])
+
+ def test_goal_text_refuses_task_without_thread(self):
+ task = {"name": "g", "cwd": "/tmp",
+ "sandbox": None, "max_retries": 0, "retry_count": 0,
+ "turns": [], "status": "completed"}
+ saved, fake = self._run_main(task, "new goal")
+ self.assertEqual(saved["status"], "completed")
+ self.assertEqual(fake.resumed, [])
+
+
+if __name__ == "__main__":
+ unittest.main()