feat: side-thread task kind (Muse role alignment phase 2)
ci / test (push) Successful in 25m2s

Tasks gain kind='main'|'side' (NULL=main). A side thread hangs under a
parent task, defaults to the local executor, never builds a worktree,
branch or container unless explicitly asked, and writes its conclusion
back to the parent via task_followups on terminal transition.

- server: kind validation + side-thread gates (parent required, no
  coordinator_parts/upload_id/environment_id, parent must not be a
  coordinator), local executor default, GET /tasks kind filter,
  /worktree 404 and /fork /merge /pr 409 for side threads.
- worker: side-default shunt in _run_attempt (no-worktree scratch cwd,
  in-memory plan=1 when a repo is present, seed/checkpoint skipped),
  _writeback_side_thread, marker task-create kind field + inheritance,
  side threads excluded from marker spawn counts.
- host/sandbox: --no-worktree plumbing for dispatch/goal/popen
  (Local + Docker), scratch cwd work/side-threads/<task-id>.
- console (classic only): kind filter + [旁聊] rows + '开旁聊' entry,
  side-thread dispatch mode, side-thread list in detail, merge/PR/fork
  hidden for side threads; build exit 0, console.html sha df39db5a.
- tests: new test_sidethread.py (29 tests) + kind backfill tests in
  test_p2_migrations.py; full cloud suite 3900 tests OK (skipped=13).

Design: verification/codex-muse-role-alignment-design-20261108.md
Summary: verification/codex-muse-role-align-p1p2-20261108.md
Local only: not pushed, not merged, not deployed.
This commit is contained in:
host committed 2026-11-07 20:51:23 +00:00
1 parent f6df76ee8f
commit 7983567491
24 files changed
+1244 -100

No files matched your search

+105 -5
View File
@@ -1234,6 +1234,11 @@ def migrate_tasks_table(conn):
"coordinator_parts": "TEXT",
"parent_task_id": "TEXT",
"coordinator_part": "TEXT",
"kind": "TEXT",
# 旁聊(Muse 角色对齐 P2):kind 记任务类型
# ('main' | 'side',NULL = 老行 = 主聊,与 goal_mode
# NULL=0、plan NULL=0 同款惯例);parent_task_id
# 的语义从“协调员/派生父任务”扩为“父会话”。
# G10:goal 断点续跑待处理标记(1 = 已调 /goal-resume、
# 待 worker 按断点续跑;worker 认领后清掉,跑完不再
# 重复触发)。与 resume_message 不同:后者是“给旧会
@@ -1725,6 +1730,9 @@ def init_db(db_path):
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_status"
" ON tasks(status)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_tasks_parent"
" ON tasks(parent_task_id)")
conn.commit()
finally:
conn.close()
@@ -2320,6 +2328,20 @@ def _goal_signal_status(status):
return "pending_mirror_to_host_loop"
def _filter_tasks_by_kind(rows, kind_f):
"""旁聊(Muse 角色对齐 P2):GET /tasks 的 kind 过滤。
kind_f 为空时原样返回(缺省 = 全部,老行为);否则只留
`_task_to_dict` 的 kind 等于 kind_f 的行。抽成独立函数,
不让 `_list_tasks` 再长(长方法清单 pin 在 18 个,见
test_longfunc_inventory)。
"""
if not kind_f:
return rows
return [r for r in rows
if _task_to_dict(r, [], 0)["kind"] == kind_f]
class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, AutomationMixin, FullAutoMixin,
BaseHTTPRequestHandler):
"""控制面请求处理:除 /health 与 /webhooks/* 外一律要求 X-API-Key。"""
@@ -3837,6 +3859,44 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
"error": "coordinator task cannot be created"
" under a coordinator task"})
return
# 旁聊(Muse 角色对齐 P2):kind 只认 "main" | "side",
# 缺省 = 主聊;旁聊须带 parent_task_id(已在上段校验
# 存在与同项目),不得带 coordinator_parts(协调员
# 子任务与旁聊是两种父子关系),不得带 upload_id /
# environment_id(附件与环境语义都要求 worktree,
# 旁聊默认没有);父任务不得是协调员任务(协调员
# 本身不跑模型回合,旁聊挂上去没有父会话语义,且
# 会污染协调员的子任务同步快照)。
kind = body.get("kind")
if kind is None:
kind = "main"
if kind not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
if kind == "side":
if parent_task_id is None:
self._send_json(400, {
"error": "side thread requires parent_task_id"})
return
if coord_parts:
self._send_json(400, {
"error": "side thread cannot carry coordinator_parts"})
return
if parent_task is not None and parent_task.get(
"coordinator_parts"):
self._send_json(400, {
"error": "side thread parent cannot be a"
" coordinator task"})
return
if body.get("upload_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry upload_id"})
return
if body.get("environment_id") is not None:
self._send_json(400, {
"error": "side thread cannot carry environment_id"})
return
idempotency_key = body.get("idempotency_key")
if idempotency_key is not None and not isinstance(idempotency_key, str):
self._send_json(400, {"error": "idempotency_key must be a string"})
@@ -3918,7 +3978,10 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
# 未指定且无环境继承时默认 docker:空 executor(任意
# worker 可认领)会让任务落到隔离较弱的 local worker,
# 是多用户下的隔离缺口;显式 local/cloud 不受影响。
executor = _default_executor()
# 旁聊例外(P2 G1):旁聊是会话式使用,默认 local、
# 不拉容器;docker/cloud 仅显式要求时才走。
executor = "local" if kind == "side" \
else _default_executor()
# 附件绑定(upload_id 由 POST /uploads 发出):包必须存在、
# 归属当前用户、未使用、24 小时内、暂存目录还在;cloud
# 执行器没有 worktree 可 seed,直接拒绝。校验在 INSERT
@@ -4044,19 +4107,19 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
" 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, kind,"
" child_model, child_effort,"
" agent_name, agent_version, agent_instructions,"
" agent_tools, agent_tool_approvals, audit_loop)"
" 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,
parent_task_id, None, child_model, child_effort,
parent_task_id, None, kind, child_model, child_effort,
agent_name_val, agent_version_val,
agent_instructions_val, agent_tools_val,
agent_tool_approvals_val, audit_loop_val),
@@ -4145,6 +4208,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
parent_ids = query.get("parent_task_id")
if parent_ids:
parent_task_id = parent_ids[0]
# 旁聊(Muse 角色对齐 P2):kind=main|side 可选,缺省 =
# 全部(老行为不变);非法值 400。
kind_f = query["kind"][0] if query.get("kind") else None
if kind_f is not None and kind_f not in ("main", "side"):
self._send_json(400, {
"error": "kind must be 'main' or 'side'"})
return
limit = None
offset = 0
if query.get("limit"):
@@ -4181,13 +4251,15 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
s = r["status"]
status_counts[s] = status_counts.get(s, 0) + 1
if parent_task_id:
filtered = rows
filtered = _filter_tasks_by_kind(rows, kind_f)
else:
filtered = []
for r in rows:
d = _task_to_dict(r, [], 0)
if status_f and d["status"] != status_f:
continue
if kind_f and d["kind"] != kind_f:
continue
if executor_f and (d["executor"] or "") != executor_f:
continue
if q_f and q_f not in ((d["id"] or "") + " " + (d["prompt"] or "")).lower():
@@ -6484,6 +6556,14 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
# 旁聊(P2 G5):旁聊没有 worktree,/worktree 按
# “没有本地 worktree”口径回 404,不回 exists=false
# 的摘要(/files 与 /tree 同款分支已在
# _task_worktree_for_files 里回 404)。
if task.get("kind") == "side":
self._send_json(404, {
"error": "no local worktree for this task"})
return
summary = self._worktree_summary(task)
self._send_json(200, dict(summary, task_id=task_id))
@@ -6779,6 +6859,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
return
if self._reject_if_over_quota(user_id):
return
# 旁聊(P2 G6):旁聊无 worktree/分支,分叉 thread
# 历史的变体不在首版,直接 409,不等 worker/host 层
# 以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to fork"})
return
message = body.get("message")
if not message or not isinstance(message, str) \
or not message.strip():
@@ -7027,6 +7114,13 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,合并门禁的五项
# 证据(CI/目录/契约/批准/评审)无一有落点,直接 409,
# 不进门禁检查。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch to merge"})
return
_merge_ok, _merge_reason = _merge_state_decision(
task["status"])
if not _merge_ok:
@@ -8860,6 +8954,12 @@ pid 须为整数、address 须为字符串,否则 400(注册表里的
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
# 旁聊(P2 G6):旁聊无 worktree/分支,建 PR 无分支可推,
# 直接 409,不等 host 层以 500/ValueError 收尾。
if task.get("kind") == "side":
self._send_json(409, {
"error": "side thread has no worktree/branch for a PR"})
return
if task["status"] != "merged":
self._send_json(409, {
"error": "only merged tasks can create a PR",