From 798356749138f19b6755b33fcfd07290ef192acb Mon Sep 17 00:00:00 2001 From: host Date: Sat, 7 Nov 2026 20:51:23 +0000 Subject: [PATCH] feat: side-thread task kind (Muse role alignment phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. - 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. --- cloud/console-react/src/api/paths.ts | 9 +- cloud/console-react/src/api/types.ts | 5 + cloud/console-react/src/context.ts | 3 + .../src/hooks/useConsoleStore.ts | 6 + .../console-react/src/hooks/useTaskDetail.ts | 18 +- cloud/console-react/src/hooks/useTaskList.ts | 11 +- .../src/sections/DispatchSection.tsx | 77 +- .../src/sections/TaskDetailPanel.tsx | 7 +- .../src/sections/TaskListSection.tsx | 17 +- .../src/sections/detail/DetailActions.tsx | 13 +- .../src/sections/detail/DetailLists.tsx | 17 + cloud/console.html | 62 +- cloud/db.py | 3 + cloud/handler_support.py | 2 + cloud/marker_tools.py | 36 +- cloud/migrations.py | 7 +- cloud/sandbox.py | 35 +- cloud/server.py | 110 ++- cloud/tests/test_longtask_auto_resume.py | 3 +- cloud/tests/test_p2_migrations.py | 42 + cloud/tests/test_sidethread.py | 730 ++++++++++++++++++ cloud/worker.py | 118 ++- docs/codex-native-ui-alignment.md | 1 + host.py | 12 + 24 files changed, 1244 insertions(+), 100 deletions(-) create mode 100644 cloud/tests/test_sidethread.py diff --git a/cloud/console-react/src/api/paths.ts b/cloud/console-react/src/api/paths.ts index 232515d..b40816b 100644 --- a/cloud/console-react/src/api/paths.ts +++ b/cloud/console-react/src/api/paths.ts @@ -29,14 +29,19 @@ export const paths = { environmentsCreate: "/environments", environmentArchive: (id: string) => "/environments/" + enc(id) + "/archive", tasks: (pid: string) => "/tasks?project_id=" + enc(pid), - tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string) => { + tasksPage: (pid: string, limit: number, offset: number, status?: string, executor?: string, q?: string, kind?: string) => { let u = "/tasks?project_id=" + enc(pid) + "&limit=" + limit + "&offset=" + offset + "&order=desc"; if (status) u += "&status=" + enc(status); if (executor) u += "&executor=" + enc(executor); if (q && q.trim()) u += "&q=" + enc(q.trim()); + if (kind) u += "&kind=" + enc(kind); + return u; + }, + tasksByParent: (pid: string, parent: string, kind?: string) => { + let u = "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent); + if (kind) u += "&kind=" + enc(kind); return u; }, - tasksByParent: (pid: string, parent: string) => "/tasks?project_id=" + enc(pid) + "&parent_task_id=" + enc(parent), tasksCreate: "/tasks", task: (id: string) => "/tasks/" + enc(id), taskAction: (id: string, kind: string) => "/tasks/" + enc(id) + "/" + kind, diff --git a/cloud/console-react/src/api/types.ts b/cloud/console-react/src/api/types.ts index 046048f..d75b399 100644 --- a/cloud/console-react/src/api/types.ts +++ b/cloud/console-react/src/api/types.ts @@ -19,6 +19,9 @@ export interface Task { turn_id?: string; root_turn_id?: string; plan?: number | boolean; forked_from?: string; coordinator_parts?: Array<{ name: string; prompt?: string }> | null; parent_task_id?: string; coordinator_part?: string; goal_mode?: number | boolean; verify_command?: string; + // 旁聊(Muse 角色对齐 P2):服务端 _task_to_dict 回 kind, + // 缺列/NULL 的老行按 "main" 计。 + kind?: "main" | "side"; no_time_limit?: number | boolean; environment_id?: string; attachments?: string[]; model?: string; effort?: string; child_model?: string; child_effort?: string; approval?: string; @@ -71,6 +74,8 @@ export interface DispatchBody { model?: string; effort?: string; child_model?: string; child_effort?: string; approval?: string; plan?: number; goal_mode?: number; no_time_limit?: number; upload_id?: string; coordinator_parts?: Array<{ name: string; prompt?: string }>; + // 旁聊(Muse 角色对齐 P2):kind="side" 必须配 parent_task_id。 + kind?: "side"; parent_task_id?: string; } export interface ContinueBody { message: string; model?: string; effort?: string; child_model?: string; diff --git a/cloud/console-react/src/context.ts b/cloud/console-react/src/context.ts index 295d21a..fe8b445 100644 --- a/cloud/console-react/src/context.ts +++ b/cloud/console-react/src/context.ts @@ -21,6 +21,7 @@ export interface ConsoleCtx { models: string[]; modelCustomVisible: boolean; tasks: Task[]; taskCountText: string; taskError: string; refreshTasks: () => void; statusFilter: string; setStatusFilter: (v: string) => void; executorFilter: string; setExecutorFilter: (v: string) => void; searchText: string; setSearchText: (v: string) => void; + kindFilter: string; setKindFilter: (v: string) => void; filteredTasks: Task[]; taskPage: number; taskTotalPages: number; taskTotal: number; taskStatusCounts: Record; goToTaskPage: (p: number) => void; currentTask: Task | null; detailBody: string; actionMsg: string; actionReasons: string; @@ -28,6 +29,8 @@ export interface ConsoleCtx { forkVisible: boolean; setForkVisible: (v: boolean) => void; forkTask: (msg: string) => Promise; forkMsg: string; // Continue input is ContinuePanel (ChatInput); its send path is sendChatMessage below. coordChildren: Task[]; coordText: string; agents: Agent[]; agentsNote: string; + // 旁聊(Muse 角色对齐 P2):当前任务的旁聊列表 + 派发器旁聊模式的父任务。 + sideThreads: Task[]; sideParentId: string | null; setSideParentId: (v: string | null) => void; reviews: Review[]; reviewMsg: string; reviewError: string; refreshReviews: () => void; postReview: (body: ReviewBody) => Promise; submitFindings: (id: string) => void; traceEntries: TraceEntry[]; traceNote: string; traceDone: string; traceKind: string; setTraceKind: (v: string) => void; downloadTrace: () => void; traceMsg: string; sendChatMessage: (text: string) => Promise; chatMsg: string; diff --git a/cloud/console-react/src/hooks/useConsoleStore.ts b/cloud/console-react/src/hooks/useConsoleStore.ts index 4200972..f607c56 100644 --- a/cloud/console-react/src/hooks/useConsoleStore.ts +++ b/cloud/console-react/src/hooks/useConsoleStore.ts @@ -23,6 +23,10 @@ export function useConsoleStore(): ConsoleCtx { const onTasksReload = useCallback(() => { void taskList.refreshTasks(undefined, projectIdRef.current); }, [taskList]); const detail = useTaskDetail(getRole, onTasksReload); const [refreshStamp, setRefreshStamp] = useState("--:--:--"); + // 旁聊(Muse 角色对齐 P2):派发器的旁聊父任务。列表“开旁聊” + // 点选主聊后填这里,派发器读到即进旁聊模式;提交成功或点 + // “取消旁聊模式”后清空。放胶水层,不进 App.tsx。 + const [sideParentId, setSideParentId] = useState(null); const selectProject = useCallback((id: string) => { catalog.setProjectId(id); @@ -126,6 +130,7 @@ export function useConsoleStore(): ConsoleCtx { refreshTasks: () => void taskList.refreshTasks(undefined, projectIdRef.current), statusFilter: taskList.statusFilter, setStatusFilter: taskList.setStatusFilter, executorFilter: taskList.executorFilter, setExecutorFilter: taskList.setExecutorFilter, + kindFilter: taskList.kindFilter, setKindFilter: taskList.setKindFilter, searchText: taskList.searchText, setSearchText: taskList.setSearchText, filteredTasks: taskList.filteredTasks, taskPage: taskList.page, taskTotalPages: taskList.totalPages, taskTotal: taskList.taskTotal, @@ -135,6 +140,7 @@ export function useConsoleStore(): ConsoleCtx { doAction: detail.doAction, taskCost: detail.taskCost, costBox: detail.costBox, costSummary: detail.costSummary, forkVisible: detail.forkVisible, setForkVisible: detail.setForkVisible, forkTask: detail.forkTask, forkMsg: detail.forkMsg, coordChildren: detail.coordChildren, coordText: "", agents: detail.agents, agentsNote: detail.agentsNote, + sideThreads: detail.sideThreads, sideParentId, setSideParentId, reviews: detail.reviews, reviewMsg: detail.reviewMsg, reviewError: detail.reviewError, refreshReviews: detail.refreshReviews, postReview: detail.postReview, submitFindings: detail.submitFindings, traceEntries: detail.traceEntries, traceNote: detail.traceNote, traceDone: detail.traceDone, diff --git a/cloud/console-react/src/hooks/useTaskDetail.ts b/cloud/console-react/src/hooks/useTaskDetail.ts index b3ea4df..8660a5e 100644 --- a/cloud/console-react/src/hooks/useTaskDetail.ts +++ b/cloud/console-react/src/hooks/useTaskDetail.ts @@ -19,6 +19,9 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR const [forkVisible, setForkVisible] = useState(false); const [forkMsg, setForkMsg] = useState(""); const [coordChildren, setCoordChildren] = useState([]); + // 旁聊(Muse 角色对齐 P2):当前任务挂着的旁聊线程(kind=side + // 且 parent_task_id=本任务),与协调员子任务同款拉取方式。 + const [sideThreads, setSideThreads] = useState([]); const [agents, setAgents] = useState([]); const [agentsNote, setAgentsNote] = useState(""); const [reviews, setReviews] = useState([]); @@ -71,6 +74,13 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR } catch { setCoordChildren([]); } }, []); + const loadSideThreads = useCallback(async (task: Task) => { + try { + const d = await api<{ tasks: Task[] }>(paths.tasksByParent(task.project_id || "", task.id, "side")); + setSideThreads(d.tasks || []); + } catch { setSideThreads([]); } + }, []); + const loadCost = useCallback(async (taskId: string) => { try { const c = await api(paths.taskCost(taskId)); @@ -248,6 +258,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR void startTrace(taskId, t.status); void loadAgents(taskId); void loadCoordChildren(t); + void loadSideThreads(t); void loadChatHistory(taskId); void loadFollowups(taskId); void loadPeerMessages(taskId); @@ -259,7 +270,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR setCurrentTask(null); setDetailBody("加载失败:" + e.message); } - }, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]); + }, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadSideThreads, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]); const refreshDetailLists = useCallback(async () => { const t = currentTaskRef.current; @@ -277,12 +288,13 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR void loadReviews(t.id); void loadApprovals(t.id); void loadCoordChildren(t); + void loadSideThreads(t); void loadAgents(t.id); void loadPeerMessages(t.id); void loadThread(t.id); void loadThreads(t.id); void loadCost(t.id); - }, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]); + }, [loadFollowups, loadChatHistory, loadReviews, loadApprovals, loadCoordChildren, loadSideThreads, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]); const doAction = useCallback(async (kind: string) => { const t = currentTaskRef.current; @@ -423,7 +435,7 @@ export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksR return { currentTask, detailBody, actionMsg, actionReasons, costBox, costSummary, forkVisible, setForkVisible, forkMsg, - coordChildren, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone, + coordChildren, sideThreads, agents, agentsNote, reviews, reviewMsg, reviewError, traceEntries, traceNote, traceDone, traceKind, setTraceKind, traceMsg, followups, approvals, approvalMsg, peerMessages, peerMsg, threadText, threadGateNote, threadMsg, threadList, chatMsg, chatHistory, showDetail, refreshDetailLists, doAction, taskCost, forkTask, postReview, submitFindings, diff --git a/cloud/console-react/src/hooks/useTaskList.ts b/cloud/console-react/src/hooks/useTaskList.ts index 56aae73..5ed181f 100644 --- a/cloud/console-react/src/hooks/useTaskList.ts +++ b/cloud/console-react/src/hooks/useTaskList.ts @@ -21,13 +21,14 @@ export function useTaskList() { const [taskError, setTaskError] = useState(""); const [statusFilter, setStatusFilter] = useState(""); const [executorFilter, setExecutorFilter] = useState(""); + const [kindFilter, setKindFilter] = useState(""); const [searchText, setSearchText] = useState(""); const [page, setPageState] = useState(0); const [taskTotal, setTaskTotal] = useState(0); const [taskStatusCounts, setTaskStatusCounts] = useState>({}); const pageRef = useRef(0); - const filterRef = useRef({ status: "", executor: "", q: "" }); - filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText }; + const filterRef = useRef({ status: "", executor: "", q: "", kind: "" }); + filterRef.current = { status: statusFilter, executor: executorFilter, q: searchText, kind: kindFilter }; const lastProjectRef = useRef(""); const refreshTasks = useCallback(async (pid?: string, currentPid?: string, pageOverride?: number) => { @@ -38,7 +39,7 @@ export function useTaskList() { const f = filterRef.current; try { const d = await api<{ tasks: Task[]; total?: number; status_counts?: Record }>( - paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q)); + paths.tasksPage(id, TASK_PAGE_SIZE, p * TASK_PAGE_SIZE, f.status, f.executor, f.q, f.kind)); setTasks(d.tasks || []); setTaskTotal(d.total ?? (d.tasks || []).length); setTaskStatusCounts(d.status_counts || {}); @@ -66,13 +67,14 @@ export function useTaskList() { const t = setTimeout(() => { void refreshTasks(lastProjectRef.current, undefined, 0); }, 300); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [statusFilter, executorFilter, searchText]); + }, [statusFilter, executorFilter, searchText, kindFilter]); // Filter/project changes reset to page 1: the old page may not // exist under the new filter, and staying on it would show an // empty page with a misleading "共 N 个" count. const setStatusFilterPage = useCallback((v: string) => { setStatusFilter(v); setPage(0); }, [setPage]); const setExecutorFilterPage = useCallback((v: string) => { setExecutorFilter(v); setPage(0); }, [setPage]); + const setKindFilterPage = useCallback((v: string) => { setKindFilter(v); setPage(0); }, [setPage]); const setSearchTextPage = useCallback((v: string) => { setSearchText(v); setPage(0); }, [setPage]); const refreshTasksFirstPage = useCallback(async (pid?: string) => { setPage(0); @@ -99,6 +101,7 @@ export function useTaskList() { return { tasks, taskError, statusFilter, setStatusFilter: setStatusFilterPage, executorFilter, setExecutorFilter: setExecutorFilterPage, + kindFilter, setKindFilter: setKindFilterPage, searchText, setSearchText: setSearchTextPage, filteredTasks, taskCountText, refreshTasks, refreshTasksFirstPage, page, totalPages, taskTotal, taskStatusCounts, goToPage, dispatch, diff --git a/cloud/console-react/src/sections/DispatchSection.tsx b/cloud/console-react/src/sections/DispatchSection.tsx index b0a01c2..16d2a1f 100644 --- a/cloud/console-react/src/sections/DispatchSection.tsx +++ b/cloud/console-react/src/sections/DispatchSection.tsx @@ -1,16 +1,22 @@ import { useRef, useState } from "react"; import { useConsole } from "../context"; import { Card, Bar } from "../components/ui"; -import { gatherAttachments } from "../api/client"; +import { gatherAttachments, shortId } from "../api/client"; import { EFFORTS } from "../api/types"; export default function DispatchSection() { const ctx = useConsole(); + // 旁聊模式(Muse 角色对齐 P2):列表“开旁聊”点选主聊后, + // sideParentId 即父任务 id;此时隐藏环境/协调员/附件, + // 执行器默认不指定(服务端旁聊默认 local),提交带 + // kind="side" + parent_task_id。 + const isSide = !!ctx.sideParentId; const [prompt, setPrompt] = useState(""); const [coordParts, setCoordParts] = useState(""); const [goalMode, setGoalMode] = useState(false); const [planMode, setPlanMode] = useState(false); const [executor, setExecutor] = useState("docker"); + const [sideExecutor, setSideExecutor] = useState(""); const [model, setModel] = useState(""); const [modelCustom, setModelCustom] = useState(""); const [effort, setEffort] = useState(""); @@ -28,8 +34,15 @@ export default function DispatchSection() { if (!ctx.projectId) { setMsg("先选项目"); return; } if (!prompt.trim()) { setMsg("prompt 必填"); return; } const body: any = { project_id: ctx.projectId, prompt: prompt.trim() }; - if (ctx.envSelect) body.environment_id = ctx.envSelect; - if (executor) body.executor = executor; + if (isSide) { + body.kind = "side"; + body.parent_task_id = ctx.sideParentId; + // 旁聊默认不指定执行器(服务端填 local);显式选了才送。 + if (sideExecutor) body.executor = sideExecutor; + } else { + if (ctx.envSelect) body.environment_id = ctx.envSelect; + if (executor) body.executor = executor; + } const m = modelCustom.trim() || model; if (m) body.model = m; if (effort) body.effort = effort; @@ -52,15 +65,20 @@ export default function DispatchSection() { // 单独发不带 no_time_limit 的 goal_mode。 if (goalMode) { body.goal_mode = 1; body.no_time_limit = 1; } const coordRaw = coordParts.trim(); - if (coordRaw) { + if (coordRaw && !isSide) { try { body.coordinator_parts = JSON.parse(coordRaw); } catch { setMsg("协调员子任务 JSON 解析失败"); return; } if (!Array.isArray(body.coordinator_parts)) { setMsg("协调员子任务必须是数组"); return; } if (goalMode || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; } } - const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null); + const atts = isSide ? [] : gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null); try { const result = await ctx.dispatch(body, atts); - setMsg(result); + if (isSide) { + setMsg("旁聊已创建(无 worktree)"); + ctx.setSideParentId(null); + } else { + setMsg(result); + } setPrompt(""); setCoordParts(""); if (filesRef.current) filesRef.current.value = ""; if (dirRef.current) dirRef.current.value = ""; @@ -70,20 +88,35 @@ export default function DispatchSection() { return ( + {isSide ? ( +
旁聊模式:挂在父任务 {shortId(ctx.sideParentId)} 下,不建 worktree/分支/容器;终态后结论回写父级。 + {" "} +
+ ) : null} - - + {isSide ? null : ( + + )} + {isSide ? ( + + ) : ( + + )}