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

+110 -6
View File
@@ -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 <host 名>
--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(宿主在该网桥上的地址)。