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:
1 parent
6da3745127
commit
f4443dca4f
7 files changed
+1120
-74
No files matched your search
+61
-7
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user