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:
host committed 2026-10-27 04:40:11 +08:00
1 parent cf293914d9
commit 3a4997d346
9 files changed
+823 -20

No files matched your search

+158
View File
@@ -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):
"""把终态失败任务重新排队:只改状态,不直接执行。