feat: 子任务默认模型/强度(child_model/child_effort)

主任务可显式声明派生默认,或在 prompt 里写“子任务用 gpt-6-luna max”
由服务端解析填入;marker task-create 与协调员分工未显式写模型/强度时
按 块/分工 > 派生默认 > 父任务模型/强度 继承,fork 复制派生默认,
continue 可改派生默认;console 新建/继续表单与任务详情同步展示。
This commit is contained in:
host committed 2026-10-29 05:27:22 +08:00
1 parent f99e667222
commit 14757f15c4
7 files changed
+588 -25

No files matched your search

+88 -11
View File
@@ -71,6 +71,7 @@ from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
from cloud.peer_messages import row_get as _row_get
from cloud.marker_tools import parse_spawn_defaults as _parse_spawn_defaults
from cloud.automations import cancel_automation as _auto_cancel
from cloud.automations import create_automation as _auto_create
from cloud.automations import get_automation as _auto_get
@@ -1076,6 +1077,13 @@ def migrate_tasks_table(conn):
"model": "TEXT",
"effort": "TEXT",
"approval": "TEXT",
# 派生默认(子任务模型/强度):NULL = 没有单独声明,
# 派生时先取它、再取本任务的 model / effort;显式
# 传 child_model / child_effort,或建任务 prompt 里
# 写明“子任务用 X max”这类显式指令时由服务端解析
# 填入(见 marker_tools.parse_spawn_defaults)。
"child_model": "TEXT",
"child_effort": "TEXT",
# 计划模式(只读)与分支来源:plan=1 时 worker 用
# read-only 沙箱;forked_from 记源任务 id(非分支为 NULL)。
"plan": "INTEGER",
@@ -1520,6 +1528,8 @@ def _task_to_dict(row, attachments=None):
"worktree_host": _row_get(row, "worktree_host"),
"model": _row_get(row, "model"),
"effort": _row_get(row, "effort"),
"child_model": _row_get(row, "child_model"),
"child_effort": _row_get(row, "child_effort"),
"approval": _row_get(row, "approval"),
"plan": _row_get(row, "plan") or 0,
"forked_from": _row_get(row, "forked_from"),
@@ -3837,6 +3847,34 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "approval must be ask, auto or full"})
return
# 派生默认:本任务派生的子任务(marker task-create、
# 协调员分工)未显式写模型/强度时用的模型/强度。
# 显式字段优先;缺省时若 prompt 里有“子任务用 X max”
# 这类显式指令,由 parse_spawn_defaults 解析填入——
# 只认带派生主语的显式指令,裸说“主线用 X”不算,
# 那是本任务自己的 model,不应被误当成派生默认。
# 两者都缺省(NULL)时派生走本任务 model / effort,
# 再缺省才走 worker 默认,行为与过去一致。
child_model = body.get("child_model")
if child_model is not None:
if not isinstance(child_model, str) or not child_model.strip():
self._send_json(400, {
"error": "child_model must be a non-empty string"})
return
child_model = child_model.strip()
child_effort = body.get("child_effort")
if child_effort is not None and child_effort not in (
"low", "medium", "high", "xhigh", "max"):
self._send_json(400, {
"error": "child_effort must be low, medium, high,"
" xhigh or max"})
return
if child_model is None or child_effort is None:
parsed = _parse_spawn_defaults(prompt)
if child_model is None:
child_model = parsed.get("model")
if child_effort is None:
child_effort = parsed.get("effort")
# 计划模式:只读沙箱,不改任何文件;与“完全访问”
# (danger-full-access)语义直接冲突,拒绝建任务。
plan = 1 if body.get("plan") in (True, 1) else 0
@@ -4067,16 +4105,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" goal, verify_command, goal_max_rounds,"
" model, effort, approval, plan, goal_mode,"
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part)"
" parent_task_id, coordinator_part,"
" child_model, child_effort)"
" 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, plan, goal_mode,
environment_id,
json.dumps(coord_parts) if coord_parts else None,
None, None),
None, None, child_model, child_effort),
)
for rel_path, size in bundle_files:
conn.execute(
@@ -6089,11 +6128,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _fork_task(self, user_id, task_id):
"""会话分支:从已结束任务的 thread 分叉出新任务。
新任务继承源任务的 executor/模型/强度/权限/计划模式,
不继承 goal(分支是普通任务,首轮 prompt 就是这里传的
message)。worker 认领后先调 host.py fork(分叉 thread
历史 + 拷 worktree 文件),再以 message 跑首轮。
以及派生默认 child_model / child_effort(分支会话自己
再派生时仍用同一派生默认),不继承 goal(分支是普通
任务,首轮 prompt 就是这里传的 message)。worker 认领
后先调 host.py fork(分叉 thread 历史 + 拷 worktree
文件),再以 message 跑首轮。
只能从终态任务分支(进行中的 thread 正在变,分叉快照
不可信);message 必填——分支没有首条消息就没有方向。
"""
@@ -6137,15 +6177,16 @@ 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, plan, forked_from)"
" model, effort, approval, plan, forked_from,"
" child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?)",
" NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
(new_id, task["project_id"], message, task["priority"],
user_id, now, now,
task["executor"] or _default_executor(),
task["goal_max_rounds"] or 3, task["model"],
task["effort"], task["approval"], task["plan"] or 0,
task_id),
task_id, task["child_model"], task["child_effort"]),
)
# 分支带走附件清单(worktree 本身已含这些文件,分支
# 不再 seed;清单只作展示,bundle 删了也不影响分支)。
@@ -6412,7 +6453,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
必须是终端且非 merged(queued/running 请用后续消息,
merged 的 worktree 已删、请分支,各 409)。选项与新建
任务同款校验:model(空串 = 跟随配置)、effort、approval、
plan、goal_mode;不传的选项沿用任务原值。冲突与新建
plan、goal_mode,以及派生默认 child_model / child_effort
(空串 = 清空);不传的选项沿用任务原值。冲突与新建
一致:plan + approval=full、plan + goal_mode 各 400。
goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
verify_command 清空、轮次 0),worker 走 goal-resume
@@ -6483,6 +6525,35 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "effort must be low, medium, high, xhigh or max"})
return
# 派生默认同款:child_model 空串 = 清空(跟随本任务
# model / effort),child_effort 空串 = 清空;不传沿用原值。
child_model = None
child_model_present = "child_model" in body
if child_model_present:
if body.get("child_model") is not None \
and not isinstance(body.get("child_model"), str):
self._send_json(400, {
"error": "child_model must be a string"})
return
raw_child_model = body.get("child_model")
child_model = raw_child_model.strip() \
if isinstance(raw_child_model, str) else None
if child_model == "":
child_model = None
child_effort = None
child_effort_present = "child_effort" in body
if child_effort_present:
raw_child_effort = body.get("child_effort")
if raw_child_effort is None or raw_child_effort == "":
child_effort = None
elif raw_child_effort not in (
"low", "medium", "high", "xhigh", "max"):
self._send_json(400, {
"error": "child_effort must be low, medium, high,"
" xhigh or max"})
return
else:
child_effort = raw_child_effort
approval = body.get("approval")
if approval is not None and approval not in (
"ask", "auto", "full"):
@@ -6518,6 +6589,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if effort is not None:
sets.append("effort = ?")
vals.append(effort)
if child_model_present:
sets.append("child_model = ?")
vals.append(child_model)
if child_effort_present:
sets.append("child_effort = ?")
vals.append(child_effort)
if approval is not None:
sets.append("approval = ?")
vals.append(approval)