diff --git a/cloud/console.html b/cloud/console.html
index 9a688f6..c20bec4 100644
--- a/cloud/console.html
+++ b/cloud/console.html
@@ -236,6 +236,21 @@
+
+
+
+
+
+子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。
goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止。
附件(放进任务工作区,图片与普通文件不区分):
@@ -315,6 +330,15 @@
+
+
@@ -481,6 +505,8 @@ async function loadRelayModels() {
sel.innerHTML = "" + opts;
var csel = $("#continueModel");
if (csel) csel.innerHTML = "" + opts;
+ var chsel = $("#childModelSelect");
+ if (chsel) chsel.innerHTML = "" + opts;
$("#modelCustom").style.display = "none";
} catch (e) {
$("#modelCustom").style.display = "inline";
@@ -995,6 +1021,7 @@ async function showDetail(taskId) {
esc(STATUS_EXPLAIN[t.status] || "未知状态") + ") | " +
"executor " + esc(t.executor || "") + " | 主机 " + esc(t.worktree_host || "") +
" | 模型 " + esc(t.model || "默认") + " | 强度 " + esc(t.effort || "默认") +
+ ((t.child_model || t.child_effort) ? " | 子任务默认 " + esc(t.child_model || "跟随本任务") + " / " + esc(t.child_effort || "跟随本任务") : "") +
" | 权限 " + esc(t.approval || "默认") +
(t.plan ? " | 计划模式(只读)" : "") +
(t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") +
@@ -1432,6 +1459,10 @@ $("#createTask").onclick = async function () {
if (model) body.model = model;
var eff = $("#effortSelect").value;
if (eff) body.effort = eff;
+ var cmodel = $("#childModelCustom").value.trim() || $("#childModelSelect").value;
+ if (cmodel) body.child_model = cmodel;
+ var ceff = $("#childEffortSelect").value;
+ if (ceff) body.child_effort = ceff;
var ap = $("#approvalSelect").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?")) return;
@@ -1507,6 +1538,10 @@ $("#btnContinueConfirm").onclick = async function () {
if (model) body.model = model;
var eff = $("#continueEffort").value;
if (eff) body.effort = eff;
+ var cmodel = $("#continueChildModel").value.trim();
+ if (cmodel) body.child_model = cmodel;
+ var ceff = $("#continueChildEffort").value;
+ if (ceff) body.child_effort = ceff;
var ap = $("#continueApproval").value;
if (ap) {
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认继续吗?")) return;
diff --git a/cloud/coordinator.py b/cloud/coordinator.py
index 791e2a6..e3dd37a 100644
--- a/cloud/coordinator.py
+++ b/cloud/coordinator.py
@@ -128,7 +128,11 @@ def ensure_columns(conn):
for name, coltype in {
"coordinator_parts": "TEXT",
"parent_task_id": "TEXT",
- "coordinator_part": "TEXT"}.items():
+ "coordinator_part": "TEXT",
+ # 派生默认列:协调员子任务 INSERT 也写这两列,
+ # 老库缺列时这里补上,否则 INSERT 会炸。
+ "child_model": "TEXT",
+ "child_effort": "TEXT"}.items():
if name not in cols:
try:
conn.execute(
diff --git a/cloud/db.py b/cloud/db.py
index 0eda9e1..51b08a0 100644
--- a/cloud/db.py
+++ b/cloud/db.py
@@ -841,6 +841,8 @@ POSTGRES_SCHEMA = (
"goal_mode INTEGER, "
"model TEXT, "
"effort TEXT, "
+ "child_model TEXT, "
+ "child_effort TEXT, "
"approval TEXT, "
"plan INTEGER, "
"forked_from TEXT, "
@@ -1033,6 +1035,8 @@ def init_postgres_schema(conn):
("worktree_host", "TEXT"),
("goal_mode", "INTEGER"),
("environment_id", "TEXT"),
+ ("child_model", "TEXT"),
+ ("child_effort", "TEXT"),
("resume_message", "TEXT")):
if _name not in tcols:
conn.execute(
diff --git a/cloud/marker_tools.py b/cloud/marker_tools.py
index 19e351f..904f7b2 100644
--- a/cloud/marker_tools.py
+++ b/cloud/marker_tools.py
@@ -25,7 +25,12 @@ wait-threads / task-cancel / task-retry。
fork)。新任务 prompt 由标记块给,不继承父任务的历史;
环境基线继承父任务的 environment_id,但 worker 会给它独立
的 tenant/worktree,不动父任务的 cwd。父任务未提交的改动
- 不会自动带过去。
+ 不会自动带过去。模型/强度继承优先级:块里显式写 >
+ 父任务的 child_model / child_effort(派生默认)> 父任务
+ 自身的 model / effort > worker 默认(NULL 时由认领方补)。
+ 子任务行上同时记下父任务的 child_model / child_effort,
+ 这样隔代派生时派生默认仍然沿用,不会因为中间一层没写
+ 就丢回 worker 默认。
- task-poll / task-read:按任务 id 轮询阅读。task-poll 按
interval 查库直到终态或超时;task-read 是同款别名,
不传 wait_seconds 时立即读一次快照,传了则等这么久再读
@@ -185,6 +190,118 @@ def extract_task_creates(text):
return out
+_SPAWN_SUBJECT_ZH = ("新建的任务", "派生任务", "新建任务",
+ "子任务", "子代理", "新任务", "新会话")
+_SPAWN_SUBJECT_EN = ("child tasks", "spawned tasks", "new tasks",
+ "new sessions", "subtasks", "subtask",
+ "child task", "new task", "new session")
+
+
+def _effort_after(text, start):
+ """在 model 后的同一子句里找强度:先找“强度/effort + 值”,
+ 再找紧跟 model 的裸强度词(如“gpt-6-luna max”)。子句以
+ 句号/换行/分号截断,跨子句的强度词不算,避免把下一句的
+ 词误当成这一句的强度。"""
+ clause = str(text)[start:start + 60]
+ for stop in ("。", "\n", ";", ";"):
+ cut = clause.find(stop)
+ if cut >= 0:
+ clause = clause[:cut]
+ labelled = re.search(
+ r"(?:强度|effort)\s*[::=\s]\s*(low|medium|high|xhigh|max)",
+ clause, re.IGNORECASE)
+ if labelled:
+ return labelled.group(1).lower()
+ trailing = re.search(
+ r"^\s*[,,、\s]*(low|medium|high|xhigh|max)\b",
+ clause, re.IGNORECASE)
+ if trailing:
+ return trailing.group(1).lower()
+ return None
+
+
+def _model_from_match(text, match, token_group):
+ """从一次正则命中里取 (model, effort):model 词本身若是
+ 强度词(如“子任务用 max”),只当强度、不当模型;模型词
+ 尾部的句点是句子标点、不是模型名的一部分,要去掉。"""
+ token = match.group(token_group).rstrip(".")
+ if not token:
+ return None, None
+ if token.lower() in EFFORTS:
+ return None, token.lower()
+ return token, _effort_after(text, match.end(token_group))
+
+
+def parse_spawn_defaults(text):
+ """从主线 prompt 里解析派生默认模型/强度,只认显式指令。
+
+ 认的句式(主语必须是派生对象,裸说“主线用 X”不算,
+ 那是本任务自己的模型、应走建任务的 model 字段):
+ - 中文:子任务/子代理/派生任务/新建任务/新任务/新会话 +
+ 用/使用/采用/模型:/模型是 + 模型词,可跟强度词或
+ “强度/effort + 强度词”,如“子代理用 gpt-6-luna max”、
+ “子任务模型:gpt-6-luna,强度:max”。
+ - 英文:subtask/child task/spawned task/new task/session +
+ use/using/with model/model: + 模型词,如
+ “subtasks use gpt-6-luna with effort max”。
+ - 中文倒装:用 + 模型词 + … + 子任务/子代理,如
+ “用 gpt-6-luna max 跑子任务”。
+ 只取第一条命中;模型词只认 ASCII 词(中转模型 id 都是
+ ASCII),“用默认模型”这类中文词不会被误当成模型。
+ 显式传了 child_model / child_effort 的任务不走本函数,
+ 本函数只在建任务时两者缺省才作兜底,且结果落在任务行
+ 的 child_model / child_effort 上、调用方可直接检查。
+ 返回 dict,只含解析到的键(都没解析到时为空 dict)。"""
+ if not text:
+ return {}
+ text = str(text)
+ zh_subject = "|".join(_SPAWN_SUBJECT_ZH)
+ zh_pattern = re.compile(
+ r"(?:%s)[^。\n;;]{0,50}?(?:用\s*|使用\s*|采用\s*"
+ r"|模型\s*[::]\s*|模型\s*是\s*)"
+ r"([A-Za-z][A-Za-z0-9._-]{1,63})" % zh_subject)
+ match = zh_pattern.search(text)
+ if match:
+ model, effort = _model_from_match(text, match, 1)
+ out = {}
+ if model:
+ out["model"] = model
+ if effort:
+ out["effort"] = effort
+ if out:
+ return out
+ en_subject = "|".join(_SPAWN_SUBJECT_EN)
+ en_pattern = re.compile(
+ r"(?:%s)\b[^.\n]{0,50}?(?:use\s+|using\s+|with\s+model\s+"
+ r"|model\s*[:=]\s*)"
+ r"([A-Za-z][A-Za-z0-9._-]{1,63})" % en_subject,
+ re.IGNORECASE)
+ match = en_pattern.search(text)
+ if match:
+ model, effort = _model_from_match(text, match, 1)
+ out = {}
+ if model:
+ out["model"] = model
+ if effort:
+ out["effort"] = effort
+ if out:
+ return out
+ rev_pattern = re.compile(
+ r"(?:用|使用|采用)\s*([A-Za-z][A-Za-z0-9._-]{1,63})"
+ r"[^。\n;;]{0,30}?(?:%s)" % zh_subject)
+ match = rev_pattern.search(text)
+ if match:
+ model, effort = _model_from_match(text, match, 1)
+ out = {}
+ if model:
+ out["model"] = model
+ if effort:
+ out["effort"] = effort
+ if out:
+ return out
+ return {}
+
+
def extract_task_polls(text):
"""提取 task-poll / task-read(含 read-thread 别名)块。
diff --git a/cloud/server.py b/cloud/server.py
index c9c812c..1a9ba23 100644
--- a/cloud/server.py
+++ b/cloud/server.py
@@ -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)
diff --git a/cloud/tests/test_child_model.py b/cloud/tests/test_child_model.py
new file mode 100644
index 0000000..87c6fdc
--- /dev/null
+++ b/cloud/tests/test_child_model.py
@@ -0,0 +1,292 @@
+"""子任务默认模型/强度(child_model / child_effort)单测。
+
+不跑真实模型、不联网:解析是纯函数;服务端用本地临时端口
++ 临时 SQLite(与 test_server_endpoints 同款骨架);worker
+派生用临时 SQLite + _deliver_marker_tools(与
+test_marker_tools 同款骨架,不走 run_one 全量流程)。
+"""
+
+import http.client
+import json
+import os
+import sqlite3
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from pathlib import Path
+
+try:
+ from cloud.auth import AuthStore
+ from cloud.marker_tools import parse_spawn_defaults
+ from cloud.server import create_server, init_db
+ from cloud.worker import Worker
+except ImportError:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+ from cloud.auth import AuthStore
+ from cloud.marker_tools import parse_spawn_defaults
+ from cloud.server import create_server, init_db
+ from cloud.worker import Worker
+
+FAKE_ENV = {"XCM_API_KEY": "test-placeholder"}
+
+
+class ParseSpawnDefaultsTestCase(unittest.TestCase):
+ def test_user_example(self):
+ self.assertEqual(
+ parse_spawn_defaults("子代理用 gpt-6-luna max"),
+ {"model": "gpt-6-luna", "effort": "max"})
+
+ def test_labelled_forms(self):
+ self.assertEqual(
+ parse_spawn_defaults("子任务模型:gpt-6-luna,强度:max"),
+ {"model": "gpt-6-luna", "effort": "max"})
+ self.assertEqual(
+ parse_spawn_defaults("子任务用 gpt-6.1-sol,effort high"),
+ {"model": "gpt-6.1-sol", "effort": "high"})
+
+ def test_english_and_reversed(self):
+ self.assertEqual(
+ parse_spawn_defaults("subtasks use gpt-6-luna with effort max"),
+ {"model": "gpt-6-luna", "effort": "max"})
+ self.assertEqual(
+ parse_spawn_defaults("用 gpt-6-luna max 跑子任务"),
+ {"model": "gpt-6-luna", "effort": "max"})
+
+ def test_no_false_positives(self):
+ self.assertEqual(
+ parse_spawn_defaults("子任务要写报告,不要用默认模型"), {})
+ self.assertEqual(
+ parse_spawn_defaults("模型选择很重要,子任务用默认配置"), {})
+ # 裸说主线用 X 是本任务自己的模型,不算派生默认。
+ self.assertEqual(parse_spawn_defaults("主线用 gpt-6-luna max"), {})
+ # 跨子句的强度词不算。
+ self.assertEqual(
+ parse_spawn_defaults("子任务用 gpt-6-luna。下一句强度是 high。"),
+ {"model": "gpt-6-luna"})
+
+
+class ServerChildModelTestCase(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.db_path = os.path.join(self.tmp.name, "control.db")
+ self.auth = AuthStore(self.db_path)
+ self.owner_id, self.owner_key = self.auth.create_user("owner")
+ self.server = create_server(self.db_path, "127.0.0.1", 0)
+ self.port = self.server.server_address[1]
+ self.thread = threading.Thread(
+ target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+ status, self.project = self._req(
+ "POST", "/projects", {"name": "demo"}, self.owner_key)
+ self.assertEqual(status, 201)
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.server.server_close()
+ self.thread.join(timeout=5)
+ try:
+ self.auth.conn.close()
+ except Exception:
+ pass
+ self.tmp.cleanup()
+
+ def _req(self, method, path, body=None, key=None):
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
+ hdrs = {}
+ payload = None
+ if body is not None:
+ payload = json.dumps(body).encode("utf-8")
+ hdrs["Content-Type"] = "application/json"
+ if key is not None:
+ hdrs["X-API-Key"] = key
+ try:
+ conn.request(method, path, body=payload, headers=hdrs)
+ resp = conn.getresponse()
+ data = resp.read()
+ finally:
+ conn.close()
+ try:
+ parsed = json.loads(data.decode("utf-8")) if data else None
+ except ValueError:
+ parsed = None
+ return resp.status, parsed
+
+ def _create(self, prompt, **extra):
+ body = {"project_id": self.project["id"], "prompt": prompt}
+ body.update(extra)
+ status, task = self._req("POST", "/tasks", body, self.owner_key)
+ self.assertEqual(status, 201, task)
+ return task
+
+ def _set_terminal(self, task_id):
+ conn = sqlite3.connect(self.db_path)
+ try:
+ conn.execute(
+ "UPDATE tasks SET status = 'completed' WHERE id = ?",
+ (task_id,))
+ conn.commit()
+ finally:
+ conn.close()
+
+ def test_create_explicit_child_fields(self):
+ task = self._create(
+ "做事", model="gpt-5.6-terra", effort="low",
+ child_model="gpt-6-luna", child_effort="max")
+ self.assertEqual(task["model"], "gpt-5.6-terra")
+ self.assertEqual(task["child_model"], "gpt-6-luna")
+ self.assertEqual(task["child_effort"], "max")
+
+ def test_create_prompt_directive_fills_child_fields(self):
+ task = self._create("子代理用 gpt-6-luna max,做三件事")
+ self.assertEqual(task["child_model"], "gpt-6-luna")
+ self.assertEqual(task["child_effort"], "max")
+ # prompt 指令只填派生默认,不改本任务自己的模型。
+ self.assertIsNone(task["model"])
+
+ def test_create_explicit_wins_over_prompt(self):
+ task = self._create(
+ "子代理用 gpt-6-luna max", child_model="gpt-6-sol",
+ child_effort="high")
+ self.assertEqual(task["child_model"], "gpt-6-sol")
+ self.assertEqual(task["child_effort"], "high")
+
+ def test_create_invalid_child_effort_400(self):
+ status, body = self._req("POST", "/tasks", {
+ "project_id": self.project["id"], "prompt": "做事",
+ "child_effort": "huge"}, self.owner_key)
+ self.assertEqual(status, 400)
+
+ def test_fork_copies_child_fields(self):
+ parent = self._create(
+ "做事", model="gpt-5.6-terra", child_model="gpt-6-luna",
+ child_effort="max")
+ self._set_terminal(parent["id"])
+ status, forked = self._req(
+ "POST", "/tasks/%s/fork" % parent["id"],
+ {"message": "继续"}, self.owner_key)
+ self.assertEqual(status, 201, forked)
+ self.assertEqual(forked["model"], "gpt-5.6-terra")
+ self.assertEqual(forked["child_model"], "gpt-6-luna")
+ self.assertEqual(forked["child_effort"], "max")
+
+ def test_continue_updates_child_fields(self):
+ task = self._create("做事")
+ self._set_terminal(task["id"])
+ status, continued = self._req(
+ "POST", "/tasks/%s/continue" % task["id"],
+ {"message": "再跑一轮", "child_model": "gpt-6-luna",
+ "child_effort": "max"}, self.owner_key)
+ self.assertEqual(status, 200, continued)
+ self.assertEqual(continued["child_model"], "gpt-6-luna")
+ self.assertEqual(continued["child_effort"], "max")
+
+
+class WorkerChildModelTestCase(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.db_path = os.path.join(self.tmp.name, "control.db")
+ init_db(self.db_path)
+ self.auth = AuthStore(self.db_path)
+ self.user_id, _key = self.auth.create_user("tester")
+ self._insert("PM", status="running", model="gpt-5.6-terra",
+ effort="low", child_model="gpt-6-luna",
+ child_effort="max")
+
+ def tearDown(self):
+ try:
+ self.auth.conn.close()
+ except Exception:
+ pass
+ self.tmp.cleanup()
+
+ def _insert(self, task_id, status="queued", **cols):
+ now = time.time()
+ conn = sqlite3.connect(self.db_path)
+ try:
+ conn.execute(
+ "INSERT INTO tasks(id, project_id, prompt, status,"
+ " idempotency_key, depends_on, priority, created_by,"
+ " created_at, updated_at, last_heartbeat)"
+ " VALUES(?, 'p1', 'do work', ?, NULL, '[]', 0, 'tester',"
+ " ?, ?, NULL)",
+ (task_id, status, now, now))
+ for key, value in cols.items():
+ conn.execute(
+ "UPDATE tasks SET %s = ? WHERE id = ?" % key,
+ (value, task_id))
+ conn.commit()
+ finally:
+ conn.close()
+
+ def _child_row(self):
+ conn = sqlite3.connect(self.db_path)
+ try:
+ return conn.execute(
+ "SELECT model, effort, child_model, child_effort"
+ " FROM tasks WHERE parent_task_id = 'PM'").fetchone()
+ finally:
+ conn.close()
+
+ def _deliver(self, creates):
+ worker = Worker(self.db_path, "/fake/host.py", env=FAKE_ENV)
+ worker._deliver_marker_tools(worker.get_task("PM"), {
+ "status": "completed", "task_creates": creates,
+ "task_polls": [], "wait_threads": [], "task_cancels": [],
+ "task_retries": []})
+
+ def test_marker_inherits_child_defaults_when_block_blank(self):
+ self._deliver([{"name": "E1", "prompt": "do e1",
+ "model": None, "effort": None, "executor": None}])
+ row = self._child_row()
+ self.assertIsNotNone(row)
+ self.assertEqual(row[0], "gpt-6-luna")
+ self.assertEqual(row[1], "max")
+ # 派生默认本身也传给子任务,隔代派生不丢。
+ self.assertEqual(row[2], "gpt-6-luna")
+ self.assertEqual(row[3], "max")
+
+ def test_marker_explicit_block_wins(self):
+ self._deliver([{"name": "E1", "prompt": "do e1",
+ "model": "gpt-6-sol", "effort": "high",
+ "executor": None}])
+ row = self._child_row()
+ self.assertEqual(row[0], "gpt-6-sol")
+ self.assertEqual(row[1], "high")
+
+ def test_marker_falls_back_to_parent_model_without_child_defaults(self):
+ conn = sqlite3.connect(self.db_path)
+ try:
+ conn.execute(
+ "UPDATE tasks SET child_model = NULL,"
+ " child_effort = NULL WHERE id = 'PM'")
+ conn.commit()
+ finally:
+ conn.close()
+ self._deliver([{"name": "E1", "prompt": "do e1",
+ "model": None, "effort": None, "executor": None}])
+ row = self._child_row()
+ self.assertEqual(row[0], "gpt-5.6-terra")
+ self.assertEqual(row[1], "low")
+
+ def test_coordinator_inherits_child_defaults(self):
+ worker = Worker(self.db_path, "/fake/host.py", env=FAKE_ENV)
+ parent = worker.get_task("PM")
+ children = worker._coord_sync_children(parent, [
+ {"name": "后端", "prompt": "做后端", "model": None,
+ "effort": None, "executor": None}])
+ self.assertEqual(len(children), 1)
+ conn = sqlite3.connect(self.db_path)
+ try:
+ row = conn.execute(
+ "SELECT model, effort FROM tasks WHERE id = ?",
+ (children[0]["id"],)).fetchone()
+ finally:
+ conn.close()
+ self.assertEqual(row[0], "gpt-6-luna")
+ self.assertEqual(row[1], "max")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/cloud/worker.py b/cloud/worker.py
index 08260d5..ffe3525 100644
--- a/cloud/worker.py
+++ b/cloud/worker.py
@@ -347,6 +347,10 @@ def _task_from_row(row):
# 任务级选项:老行缺列时 None = 跟随配置(见 server 侧)。
"model": _col("model"),
"effort": _col("effort"),
+ # 派生默认:老行缺列时 None = 没有单独声明,派生时
+ # 先取它、再取本任务的 model / effort。
+ "child_model": _col("child_model"),
+ "child_effort": _col("child_effort"),
"approval": _col("approval"),
"plan": _col("plan"),
"forked_from": _col("forked_from"),
@@ -832,6 +836,8 @@ class Worker:
"worktree_host": "TEXT",
"model": "TEXT",
"effort": "TEXT",
+ "child_model": "TEXT",
+ "child_effort": "TEXT",
"approval": "TEXT",
"plan": "INTEGER",
"forked_from": "TEXT",
@@ -2610,33 +2616,54 @@ class Worker:
rejected += 1
continue
child_id = _marker_new_id()
+ # 派生模型/强度优先级:块里显式写 >
+ # 父任务的派生默认 child_model /
+ # child_effort > 父任务自身的
+ # model / effort > NULL(认领时走
+ # worker 默认)。子任务行上同时记下
+ # 父任务的派生默认,隔代派生不丢。
+ child_model = (create["model"]
+ or task.get("child_model")
+ or task.get("model"))
+ child_effort = (create["effort"]
+ or task.get("child_effort")
+ or task.get("effort"))
conn.execute(
"INSERT INTO tasks(id, project_id, prompt,"
" status, idempotency_key, depends_on,"
" priority, created_by, created_at,"
" updated_at, last_heartbeat, executor,"
" model, effort, environment_id,"
- " parent_task_id, coordinator_part)"
+ " parent_task_id, coordinator_part,"
+ " child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]',"
- " ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL)",
+ " ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL,"
+ " ?, ?)",
(child_id, task["project_id"],
create["prompt"],
task.get("priority", 0) or 0,
task.get("created_by"), now, now,
create["executor"]
or task.get("executor"),
- create["model"] or task.get("model"),
- create["effort"] or task.get("effort"),
+ child_model,
+ child_effort,
task.get("environment_id"),
- task["id"]),
+ task["id"],
+ task.get("child_model"),
+ task.get("child_effort")),
)
direct += 1
created += 1
label = create["name"] or "未命名"
+ model_note = ""
+ if child_model or child_effort:
+ model_note = ",模型 %s,强度 %s" % (
+ child_model or "默认",
+ child_effort or "默认")
reports.append(
- "已创建任务 %s(%s),状态 queued,"
+ "已创建任务 %s(%s),状态 queued%s,"
"可用 task-poll 按该 id 轮询。"
- % (child_id, label))
+ % (child_id, label, model_note))
conn.commit()
finally:
conn.close()
@@ -3314,9 +3341,9 @@ class Worker:
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, executor,"
" model, effort, environment_id, parent_task_id,"
- " coordinator_part)"
+ " coordinator_part, child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]', ?, ?, ?, ?,"
- " NULL, ?, ?, ?, ?, ?, ?)",
+ " NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
(child_id, task["project_id"], part["prompt"],
task.get("priority", 0) or 0, task["created_by"],
now, now,
@@ -3325,9 +3352,16 @@ class Worker:
# 直接写 part 的 None,子任务会落到 worker
# 默认模型/强度,与父任务的显式选择脱节,
# 既改成本也改行为,且排查时看不出继承关系)。
- part.get("model") or task.get("model"),
- part.get("effort") or task.get("effort"),
- task.get("environment_id"), task["id"], name))
+ # 派生默认优先于父任务自身:分工显式写 >
+ # 协调员的 child_model / child_effort >
+ # 协调员自身的 model / effort。
+ part.get("model") or task.get("child_model")
+ or task.get("model"),
+ part.get("effort") or task.get("child_effort")
+ or task.get("effort"),
+ task.get("environment_id"), task["id"], name,
+ task.get("child_model"),
+ task.get("child_effort")))
self._audit(
task["project_id"], "coordinator_child_created",
"%s -> %s" % (name, child_id))