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

+255 -3
View File
@@ -445,6 +445,10 @@ def migrate_tasks_table(conn):
"model": "TEXT",
"effort": "TEXT",
"approval": "TEXT",
# 计划模式(只读)与分支来源:plan=1 时 worker 用
# read-only 沙箱;forked_from 记源任务 id(非分支为 NULL)。
"plan": "INTEGER",
"forked_from": "TEXT",
}
for name, coltype in new_cols.items():
if name not in cols:
@@ -468,6 +472,41 @@ def migrate_projects_table(conn):
conn.execute("ALTER TABLE projects ADD COLUMN auto_pr INTEGER")
def _ensure_followups_table(conn):
"""幂等建后续消息表:server init_db 与各 followup 接口都调它,
老库第一次跑到这里时自动补表,不丢数据。DOUBLE PRECISION 在
SQLite 与 Postgres 下都可用(SQLite 按 NUMERIC 亲和处理)。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS task_followups("
"id TEXT PRIMARY KEY, "
"task_id TEXT, "
"project_id TEXT, "
"body TEXT, "
"mode TEXT, "
"status TEXT, "
"created_by TEXT, "
"created_at DOUBLE PRECISION, "
"updated_at DOUBLE PRECISION)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_followups_task"
" ON task_followups(task_id, status)")
def _followup_to_dict(row):
return {
"id": row["id"],
"task_id": row["task_id"],
"project_id": row["project_id"],
"body": row["body"],
"mode": row["mode"],
"status": row["status"],
"created_by": row["created_by"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _is_postgres_backend():
backend = os.environ.get("CLOUD_DB_BACKEND") or "sqlite"
return str(backend).strip().lower() in ("postgres", "postgresql")
@@ -487,6 +526,7 @@ def init_db(db_path):
_init_postgres_schema(conn)
migrate_tasks_table(conn)
migrate_projects_table(conn)
_ensure_followups_table(conn)
conn.commit()
return
conn.execute(
@@ -515,6 +555,7 @@ def init_db(db_path):
)
migrate_tasks_table(conn)
migrate_projects_table(conn)
_ensure_followups_table(conn)
# worker 每轮 poll 都按 status 取任务(见 load_tasks 的
# WHERE status IN (...)),无索引时是全表扫描;任务表随
# 时间变大后索引是必须的。IF NOT EXISTS 保证老库幂等。
@@ -583,6 +624,8 @@ def _task_to_dict(row):
"model": _row_get(row, "model"),
"effort": _row_get(row, "effort"),
"approval": _row_get(row, "approval"),
"plan": _row_get(row, "plan") or 0,
"forked_from": _row_get(row, "forked_from"),
}
@@ -911,6 +954,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._retry_task(user_id, rest[: -len("/retry")])
elif rest.endswith("/heartbeat") and method == "POST":
self._heartbeat_task(user_id, rest[: -len("/heartbeat")])
elif rest.endswith("/followups") and method == "POST":
self._create_followup(user_id, rest[: -len("/followups")])
elif rest.endswith("/followups") and method == "GET":
self._list_followups(user_id, rest[: -len("/followups")])
elif rest.endswith("/fork") and method == "POST":
self._fork_task(user_id, rest[: -len("/fork")])
else:
self._send_json(404, {"error": "not found"})
else:
@@ -1484,6 +1533,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "approval must be ask, auto or full"})
return
# 计划模式:只读沙箱,不改任何文件;与“完全访问”
# (danger-full-access)语义直接冲突,拒绝建任务。
plan = 1 if body.get("plan") in (True, 1) else 0
if plan and approval == "full":
self._send_json(400, {
"error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
return
# goal 字段:verify_command 非空即 goal 模式(worker 走
# host.py goal 循环);goal 文本缺省用 prompt;轮次缺省 3
# (与 host.py 默认一致),非法值 400 且不建任务。
@@ -1497,6 +1553,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "verify_command must be a string"})
return
# 计划模式与 goal 模式也冲突:goal 循环就是要改文件
# 直到验测通过,只读沙箱下它永远做不成。
if plan and verify_command:
self._send_json(400, {
"error": "plan 模式与 goal 模式冲突(只读 vs 改文件)"})
return
try:
goal_max_rounds = int(body.get("goal_max_rounds", 3))
except (TypeError, ValueError):
@@ -1534,13 +1596,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, executor,"
" goal, verify_command, goal_max_rounds,"
" model, effort, approval)"
" model, effort, approval, plan)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?, ?, ?, ?)",
" ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor, goal, verify_command, goal_max_rounds,
model, effort, approval),
model, effort, approval, plan),
)
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):
@@ -1705,6 +1767,196 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
_emit({"type": "done", "status": task["status"],
"reason": "timeout"})
def _create_followup(self, user_id, task_id):
"""给进行中任务发后续消息:排队 / 直接发送。
语义(用户定稿):排队进队尾,不打断本轮,本轮结束
后按序每条再跑一轮;直接发送也不中断本轮——消息立即
送达(链路里可见),本轮跑完后立刻以它开下一轮,且
优先于排队中的消息。v1 只做运行时(含排队中)的任务:
已终态的任务 409;goal 任务 409(goal 循环自有轮次)。
权限与取消同款:项目 create_task(写操作,不能只读)。
"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
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
text = body.get("body")
if not text or not isinstance(text, str) or not text.strip():
self._send_json(400, {"error": "body required"})
return
text = text.strip()
if len(text) > 20000:
self._send_json(400, {"error": "body too long"})
return
mode = body.get("mode", "queued")
if mode not in ("queued", "direct"):
self._send_json(400, {
"error": "mode must be queued or direct"})
return
if task["status"] not in ("queued", "running"):
self._send_json(409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"],
})
return
if task.get("verify_command"):
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
return
now = time.time()
conn = self._db()
try:
_ensure_followups_table(conn)
fu_id = uuid.uuid4().hex
conn.execute(
"INSERT INTO task_followups(id, task_id, project_id,"
" body, mode, status, created_by, created_at,"
" updated_at) VALUES(?, ?, ?, ?, ?, 'pending', ?, ?, ?)",
(fu_id, task_id, task["project_id"], text, mode,
user_id, now, now),
)
conn.commit()
fu = _followup_to_dict(
conn.execute(
"SELECT * FROM task_followups WHERE id = ?",
(fu_id,),
).fetchone()
)
finally:
conn.close()
if mode == "direct":
self._append_direct_trace(task, fu_id, text)
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"followup_created", "%s:%s" % (task_id, fu_id))
self._send_json(201, fu)
def _append_direct_trace(self, task, fu_id, text):
"""直接发送的消息立即进链路:往 host 的 jsonl 里追加一条
message 条目(phase=followup-direct),运行中的链路流
下一轮 poll 就能看到它。只尽力而为:worktree 在别机时
本机文件不是 worker 写的那份,跳过(与 _trace_snapshot
的多机降级同规则);写失败也绝不让 POST 失败。"""
try:
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
return
d = _trace_dir_for(self.server)
os.makedirs(d, exist_ok=True)
entry = {"kind": "message", "id": "fu-" + fu_id,
"ts": int(time.time() * 1000),
"phase": "followup-direct", "text": text[:300]}
with open(os.path.join(
d, _trace_file_name(task["id"]) + ".jsonl"),
"a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
def _list_followups(self, user_id, task_id):
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"], "read"):
self._send_json(403, {"error": "forbidden"})
return
conn = self._db()
try:
_ensure_followups_table(conn)
rows = conn.execute(
"SELECT * FROM task_followups WHERE task_id = ?"
" ORDER BY created_at, id",
(task_id,),
).fetchall()
finally:
conn.close()
self._send_json(200, {
"followups": [_followup_to_dict(r) for r in rows]})
def _fork_task(self, user_id, task_id):
"""会话分支:从已结束任务的 thread 分叉出新任务。
新任务继承源任务的 executor/模型/强度/权限/计划模式,
不继承 goal(分支是普通任务,首轮 prompt 就是这里传的
message)。worker 认领后先调 host.py fork(分叉 thread
历史 + 拷 worktree 文件),再以 message 跑首轮。
只能从终态任务分支(进行中的 thread 正在变,分叉快照
不可信);message 必填——分支没有首条消息就没有方向。
"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
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
message = body.get("message")
if not message or not isinstance(message, str) \
or not message.strip():
self._send_json(400, {"error": "message required"})
return
message = message.strip()
if len(message) > 20000:
self._send_json(400, {"error": "message too long"})
return
if task["status"] not in TERMINAL_STATUSES:
self._send_json(409, {
"error": "只能从已结束的任务分支",
"status": task["status"],
})
return
now = time.time()
new_id = uuid.uuid4().hex
conn = self._db()
try:
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, executor,"
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, forked_from)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?)",
(new_id, task["project_id"], message, task["priority"],
user_id, now, now, task["executor"],
task["goal_max_rounds"] or 3, task["model"],
task["effort"], task["approval"], task["plan"] or 0,
task_id),
)
conn.commit()
new_task = _task_to_dict(
conn.execute(
"SELECT * FROM tasks WHERE id = ?", (new_id,)
).fetchone()
)
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "task_forked",
"%s -> %s" % (task_id, new_id))
self._send_json(201, new_task)
def _cancel_task(self, user_id, task_id):
row = self._fetch_task(task_id)
if row is None: