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”,建任务时服务端会解析成同一对字段(任务详情里可核对)。
附件(放进任务工作区,图片与普通文件不区分): @@ -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))