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

+110 -8
View File
@@ -529,6 +529,9 @@ def _task_from_row(row):
"coordinator_parts": _coord_parse_parts(_col("coordinator_parts")),
"parent_task_id": _col("parent_task_id"),
"coordinator_part": _col("coordinator_part"),
# 旁聊(Muse 角色对齐 P2):老行缺列/NULL 时按 "main"
# 计(与 server 侧 _task_to_dict 同款惯例)。
"kind": _col("kind") or "main",
# agent 定义 v2:老行缺列时 None = 未用定义,不炸。
"agent_name": _col("agent_name"),
"agent_version": _col("agent_version"),
@@ -3701,6 +3704,7 @@ class Worker:
def _execute_via_process(self, task, kind="dispatch",
deadline_seconds=None, seed_host_dir=None,
no_worktree=False,
quota_check=None):
"""进程模式执行一轮:启动、轮询、取消时杀进程组。
@@ -3724,10 +3728,12 @@ class Worker:
else:
# seed 只对 dispatch/goal 首轮有意义:run 续跑不传
# (文件已在 worktree 里);popen_task 内部同样只在
# dispatch/goal 命令上追加 --seed-dir。
# dispatch/goal 命令上追加 --seed-dir。no_worktree
# 同款只对 dispatch/goal 命令追加 --no-worktree。
proc = self.executor.popen_task(
task, kind=kind, seed_dir=seed_host_dir,
extra_env=gateway_env, model_provider=gateway_provider)
extra_env=gateway_env, model_provider=gateway_provider,
no_worktree=no_worktree)
self._running_set(task_id, proc=proc)
try:
self._set_child_pid(task_id, getattr(proc, "pid", None))
@@ -4592,6 +4598,50 @@ class Worker:
except Exception:
return False
def _writeback_side_thread(self, task, final, text):
"""旁聊终态回写父级(P2 W1):旁聊终局后写一条
task_followups 到父任务(sender_task_id=旁聊 id,
mode='queued',status='pending')。父 running 时排空
循环自动消费(带 _peer_prefix 前缀);父 queued 时
下次认领后首轮排空消费;父终态时行只作记录,
GET /tasks/{id}/followups 与 chat-history 可见,
不复活父任务。正文截 2000 字(与协调员合流报告的
单分工上限同款)。回写是尽力而为:失败返回 False,
不改终局、不重试、不炸执行。"""
parent_id = task.get("parent_task_id")
if not parent_id:
return False
text = (text or "").strip()
if final == "completed":
head, body = "[旁聊结论]", text or "(旁聊无结论文本)"
elif final == "failed":
head, body = "[旁聊失败]", text or "(无错误文本)"
else:
head, body = "[旁聊中断]", text or "(无文本)"
full = "%s %s" % (head, body)
if len(full) > 2000:
full = full[:2000] + "…(已截断)"
try:
conn = _db_connect(self.db_path)
try:
_ensure_followups_table(conn)
conn.execute(
"INSERT INTO task_followups(id, task_id, project_id,"
" body, mode, status, created_by, sender_task_id,"
" created_at, updated_at)"
" VALUES(?, ?, ?, ?, 'queued', 'pending', ?, ?,"
" ?, ?)",
(uuid.uuid4().hex, parent_id,
task["project_id"], full, task.get("created_by"),
task["id"], time.time(), time.time()),
)
conn.commit()
return True
finally:
conn.close()
except Exception:
return False
def _poll_task_snapshot(self, task, poll):
"""轮询一个目标任务:每 interval 查一次库状态,直到终态、
超时或父任务被取消。等待期间不烧模型费,但占着本 worker
@@ -4889,17 +4939,26 @@ class Worker:
child_effort = (create["effort"]
or task.get("child_effort")
or task.get("effort"))
# 旁聊继承(P2 G7):旁聊内 marker 派生
# 的子任务默认继承父 kind='side'(除非
# 块里显式指定 kind),否则子任务会以
# 主聊身份建 worktree/分支,旁聊的无
# worktree 承诺被绕过;执行器已按
# create["executor"] or 父 executor 继承。
child_kind = create.get("kind") or (
"side" if task.get("kind") == "side"
else None)
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, kind,"
" child_model, child_effort)"
" VALUES(?, ?, ?, 'queued', NULL, '[]',"
" ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, NULL,"
" ?, ?)",
" ?, ?, ?)",
(child_id, task["project_id"],
create["prompt"],
task.get("priority", 0) or 0,
@@ -4910,6 +4969,7 @@ class Worker:
child_effort,
task.get("environment_id"),
task["id"],
child_kind,
task.get("child_model"),
task.get("child_effort")),
)
@@ -5624,6 +5684,22 @@ class Worker:
task_id = task["id"]
result = None
error = None
# 旁聊默认执行(P2 G4):kind='side' 且执行器是 local
# (旁聊默认,未显式要求 docker/cloud)时不建 worktree/
# 分支;显式 docker/cloud 走既有语义。显式 local 与
# 默认 local 在行上不可区分(都是 local),一律走
# 无 worktree 路径。
_side_default = task.get("kind") == "side" and \
(task.get("executor") or "local") == "local"
if _side_default and (task.get("repo") or
getattr(self.executor, "repo", None)):
# 有 repo 的旁聊:cwd 就是 repo,强制 plan=1
# (只读沙箱),不得把 repo 写脏;plan 经
# dispatch --sandbox read-only 透传到 codex。
# 只改内存里的 task dict,不写库(plan 列是任务
# 创建时的属性,旁聊的只读是执行层强制)。
task = dict(task)
task["plan"] = 1
# goal 模式:goal_mode=1(新语义,prompt 即目标)或带
# verify_command(旧语义)时走 host.py goal 循环,不走
# 普通 dispatch。goal 循环内部已多轮重试,worker 级
@@ -5633,7 +5709,10 @@ class Worker:
# 附件 seed:dispatch/goal 首轮才用;run 续跑不传(文件已
# 在 worktree 里)。cloud 执行器没有 worktree 可 seed,
# 创建时本应已拒绝,这里是兜底防线(直接失败,不静默跑)。
seed_host_dir = self._task_seed_dir(task)
# 旁聊默认执行不 seed(创建时已拒 upload_id,附件行恒为空,
# 这里跳过 _task_seed_dir 的一次查库)。
seed_host_dir = None if _side_default \
else self._task_seed_dir(task)
if seed_host_dir and isinstance(self.executor, CloudExecutor):
return ({"executor": "cloud", "task_id": task_id,
"status": "failed", "stdout": "",
@@ -5678,9 +5757,11 @@ class Worker:
_quota_watchdog_thread = None
# P0 三件其一:goal 任务起断点镜像线程(任何执行器);
# 非 goal 任务没有断点语义,不起。终局镜像在 finally 补。
# 旁聊默认执行不起镜像(旁聊的恢复走 thread 续跑,不走
# 断点镜像;旁聊 goal 同款)。
_mirror_stop = None
_mirror_thread = None
if goal_mode:
if goal_mode and not _side_default:
try:
_mirror_stop = threading.Event()
_mirror_thread = threading.Thread(
@@ -5765,6 +5846,7 @@ class Worker:
task, kind=popen_kind,
deadline_seconds=_effective_task_timeout,
seed_host_dir=seed_host_dir,
no_worktree=_side_default,
quota_check=quota_check)
combined = (result.get("stdout", "") or "") + \
(result.get("stderr", "") or "")
@@ -5899,7 +5981,8 @@ class Worker:
result = self.executor.goal_task(
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
model_provider=gateway_provider,
no_worktree=_side_default)
else:
result = self.executor.goal_task(task)
else:
@@ -5907,7 +5990,8 @@ class Worker:
result = self.executor.dispatch_task(
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
model_provider=gateway_provider,
no_worktree=_side_default)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
@@ -8968,6 +9052,12 @@ class Worker:
# 只留档);不计数派发(平台/协调员)时为空操作。
self._record_ledger_attempt(task_id, "interrupted",
_ledger_counted)
# 旁聊终态回写(P2 W1):取消也是终态,父级应收到
# 中断说明;回写失败不挡取消返回。
if task.get("kind") == "side":
self._writeback_side_thread(
task, "interrupted",
(result or {}).get("stdout", "") or "cancelled")
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
@@ -9083,6 +9173,18 @@ class Worker:
message="audit loop wire failed: %s" % exc)
except Exception:
pass
# 旁聊终态回写(P2 W1):只在终局真正写回且未被审计
# 推回(回 queued 不是终态)时写一条 task_followups
# 到父任务;父 running 时排空消费,父 queued 时下次
# 认领后首轮排空消费,父终态时只作记录、不复活父。
# final 已是审计接线后的终局(含 completed/failed/
# interrupted;配额中断的 final 也是 interrupted)。
if (task.get("kind") == "side" and final_written
and not audit_loop_pushback
and final in ("completed", "failed", "interrupted")):
self._writeback_side_thread(
task, final,
stdout if final == "completed" else (error or ""))
# 附件暂存清理:任务终局后删 bundle 目录(重试走已存在
# 续跑,文件已在 worktree 里,不再需要暂存)。
self._cleanup_task_seed(task)