feat: 计划模式 + 后续消息 + 会话分支接云端 (#65)

- 计划模式:tasks 加 plan 列;plan 与 approval=full / goal 冲突时
  400;执行器在 approval 参数后追加 --sandbox read-only(本地
  dispatch/run/goal/popen 与 Docker 内层命令全覆盖),host 任务
  存下 sandbox 后续轮次自动继承只读。
- 后续消息:新表 task_followups;POST/GET /tasks/{id}/followups
  (仅 queued/running 的非 goal 任务;direct 立即往链路追加
  phase=followup-direct 条目,本轮跑完后优先于排队执行;排队
  按 direct 优先、队内先来先服务逐条再跑一轮,同一 thread;
  一条失败即停、剩余 pending 留给重试入口;取消时回 pending)。
- 会话分支:POST /tasks/{id}/fork(仅终态任务;继承 executor/
  模型/强度/权限/计划,goal 字段置空);worker 先 fork 再跑首轮,
  首轮失败不自动重试、不重复分叉,失败后走重试入口重跑首轮。
- 修任务级模型被覆盖:worker 此前无条件写成主模型,控制台
  选的模型实际不生效;现为任务级 > worker 主模型 > 配置。
- Docker 租户准备抽成 _docker_task_context,供普通尝试与 fork 共用。
- console:新建任务计划勾选(含冲突拦截)、详情计划徽标与
  分支来源、分支按钮与表单、后续消息区、列表 [计划]/[分支] 标记。
- 测试:cloud/tests/test_task_links.py 17 个(server 校验与继承、
  worker 模型优先、只读命令、排空顺序与停排、goal 不排空、
  fork 单次性);全量 cloud 542、host 49、deploy 14。

Co-authored-by: host <host@test.local>
This commit is contained in:
openqjandhost authored and GitHub committed 2026-10-27 03:17:51 +08:00
1 parent 6da3745127
commit f4443dca4f
7 files changed
+1120 -74

No files matched your search

+61 -7
View File
@@ -187,7 +187,8 @@ class LocalExecutor(Executor):
# 不改 host.py 的 dispatch/run 签名。
self.model = model
def _base_command(self, model=None, effort=None, approval=None):
def _base_command(self, model=None, effort=None, approval=None,
sandbox=None):
cmd = [sys.executable, self.host_py_path]
if self.config_path is not None:
cmd += ["--config", self.config_path]
@@ -197,6 +198,11 @@ class LocalExecutor(Executor):
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
# 显式沙箱放最后:argparse 里后出现的 --sandbox 覆盖前面
# 的(approval=full 自带 danger-full-access);server 侧
# 已拒绝 plan+full 同存,这里是第二道防线。
if sandbox:
cmd += ["--sandbox", sandbox]
return cmd
def _command(self, task_id):
@@ -282,7 +288,9 @@ class LocalExecutor(Executor):
raise ValueError("任务缺少 prompt")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"))
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None))
cmd += ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
repo = task.get("repo") or self.repo
@@ -291,6 +299,27 @@ class LocalExecutor(Executor):
return self._result_from_runner(
task["id"], self.runner(cmd))
def fork_task(self, task, source_id, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
"""调 host.py fork:只分叉 thread 历史与工作区,不跑回合。
fork 成功后 host 任务已存在,worker 再走正常 dispatch→
已存在→run 回退跑首轮(见 worker._run_fork),不直接
在这里跑 turn。timeout/extra_* 是与 DockerExecutor 对齐
的签名,本地忽略。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not source_id:
raise ValueError("fork 缺少源任务")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"))
cmd += ["fork", "--task",
self._host_name({"id": source_id}),
"--name", self._host_name(task)]
return self._result_from_runner(
task["id"], self.runner(cmd))
def run_task(self, task) -> dict:
"""调 host.py run:在已有 host 任务上再跑一轮 turn。
@@ -305,7 +334,9 @@ class LocalExecutor(Executor):
raise ValueError("任务缺少 prompt")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"))
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None))
cmd += ["run", "--task", self._host_name(task),
"--prompt", task["prompt"]]
return self._result_from_runner(
@@ -403,7 +434,9 @@ class LocalExecutor(Executor):
raise ValueError("任务缺少 prompt")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"))
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None))
if kind == "run":
cmd += ["run", "--task", self._host_name(task),
"--prompt", task["prompt"]]
@@ -669,7 +702,7 @@ class DockerExecutor(Executor):
return cmd
def _inner_host_command(self, *args, model=None, effort=None,
approval=None):
approval=None, sandbox=None):
"""容器内 host.py 命令:python3 /app/host.py [args...]。
config_path 若已给,容器内用 /app/<basename> 指代它——
@@ -691,6 +724,8 @@ class DockerExecutor(Executor):
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
if sandbox:
cmd += ["--sandbox", sandbox]
cmd += list(args)
return cmd
@@ -828,7 +863,8 @@ class DockerExecutor(Executor):
inner = self._inner_host_command(
"dispatch", "--name", self._host_name(task),
"--task", task["prompt"], model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"))
effort=task.get("effort"), approval=task.get("approval"),
sandbox=("read-only" if task.get("plan") else None))
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
@@ -837,6 +873,23 @@ class DockerExecutor(Executor):
network=network,
extra_env=extra_env)
def fork_task(self, task, source_id, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
"""容器内调 host.py fork:只分叉,不跑回合(同本地版)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not source_id:
raise ValueError("fork 缺少源任务")
inner = self._inner_host_command(
"fork", "--task", self._host_name({"id": source_id}),
"--name", self._host_name(task),
model=task.get("model"), effort=task.get("effort"),
approval=task.get("approval"))
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
def run_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
@@ -847,7 +900,8 @@ class DockerExecutor(Executor):
inner = self._inner_host_command(
"run", "--task", self._host_name(task),
"--prompt", task["prompt"], model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"))
effort=task.get("effort"), approval=task.get("approval"),
sandbox=("read-only" if task.get("plan") else None))
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network,