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:
1 parent
f6df76ee8f
commit
7983567491
24 files changed
+1244
-100
No files matched your search
@@ -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 (
|
||||
<Card id="nav-newtask" className="composer" title="新建任务">
|
||||
{isSide ? (
|
||||
<div className="muted">旁聊模式:挂在父任务 {shortId(ctx.sideParentId)} 下,不建 worktree/分支/容器;终态后结论回写父级。
|
||||
{" "}<button type="button" onClick={() => ctx.setSideParentId(null)}>取消旁聊模式</button>
|
||||
</div>
|
||||
) : null}
|
||||
<Bar>
|
||||
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
|
||||
<option value="">环境:不指定(走项目仓库)</option>
|
||||
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
|
||||
</select>
|
||||
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
|
||||
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
|
||||
</select>
|
||||
{isSide ? null : (
|
||||
<select aria-label="环境" value={ctx.envSelect} onChange={(e) => ctx.setEnvSelect(e.target.value)}>
|
||||
<option value="">环境:不指定(走项目仓库)</option>
|
||||
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
|
||||
</select>
|
||||
)}
|
||||
{isSide ? (
|
||||
<select aria-label="执行器" value={sideExecutor} onChange={(e) => setSideExecutor(e.target.value)}>
|
||||
<option value="">执行器:不指定(默认 local)</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
|
||||
</select>
|
||||
) : (
|
||||
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
|
||||
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
|
||||
</select>
|
||||
)}
|
||||
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
|
||||
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
|
||||
</Bar>
|
||||
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
|
||||
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
|
||||
{isSide ? null : (<>
|
||||
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
|
||||
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
|
||||
</>)}
|
||||
<Bar>
|
||||
<select aria-label="模型" value={model} onChange={(e) => setModel(e.target.value)}>
|
||||
<option value="">模型:默认(跟随配置)</option>
|
||||
@@ -111,12 +144,14 @@ export default function DispatchSection() {
|
||||
</Bar>
|
||||
<div className="muted">子任务模型/强度是派生默认:本任务用 marker task-create 或协调员分工派生的子任务,未显式写模型/强度时用它;块/分工里显式写了则覆盖它。也可以不在这里填、直接在 prompt 里写“子任务用 gpt-6-luna max”,建任务时服务端会解析成同一对字段(任务详情里可核对)。</div>
|
||||
{goalMode ? <div className="muted">goal 模式:输入框内容即目标,不需要专用 prompt 与验收命令;不限轮次,模型确认达成后在回复末尾输出“目标已达成”即结束,未达成会一直运行,可用取消停止;任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
|
||||
<Bar>
|
||||
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
|
||||
<input ref={filesRef} type="file" multiple title="文件" />
|
||||
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
|
||||
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
|
||||
</Bar>
|
||||
{isSide ? null : (
|
||||
<Bar>
|
||||
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
|
||||
<input ref={filesRef} type="file" multiple title="文件" />
|
||||
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} />
|
||||
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" />
|
||||
</Bar>
|
||||
)}
|
||||
<Bar>
|
||||
<button className="seal" onClick={submit}>提交任务</button>
|
||||
<span className="muted">{msg || ctx.dispatchMsg}</span>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta";
|
||||
import DetailActions from "./detail/DetailActions";
|
||||
import BrowserPanel from "./detail/BrowserPanel";
|
||||
import ReviewPanel from "./detail/ReviewPanel";
|
||||
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
|
||||
import { CoordPanel, SideThreadPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
|
||||
|
||||
// Task inspector (redesign 2026-10-30): the old detail stack —
|
||||
// 概览 + 基本信息 + prompt + 操作 + 协调 + 子代理 + 评审 +
|
||||
@@ -134,9 +134,11 @@ export default function TaskDetailPanel() {
|
||||
<div><dt>权限</dt><dd>{t.approval || "默认"}</dd></div>
|
||||
<div><dt>项目</dt><dd>{shortId(t.project_id)}</dd></div>
|
||||
{modeText ? <div><dt>模式</dt><dd>{modeText}</dd></div> : null}
|
||||
{t.kind === "side" ? <div><dt>类型</dt><dd>旁聊(无 worktree/分支/容器)</dd></div> : null}
|
||||
{t.forked_from ? <div><dt>分支自</dt><dd>{shortId(t.forked_from)}</dd></div> : null}
|
||||
{t.coordinator_parts ? <div><dt>协调员</dt><dd>{t.coordinator_parts.length} 个子任务({t.coordinator_parts.map((p) => p.name).join("、")})</dd></div> : null}
|
||||
{t.parent_task_id ? <div><dt>协调子任务</dt><dd>父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</dd></div> : null}
|
||||
{t.parent_task_id && t.kind === "side" ? <div><dt>父会话</dt><dd>父任务 {shortId(t.parent_task_id)}{" "}<button type="button" onClick={() => ctx.showDetail(t.parent_task_id as string)}>跳转到父任务</button></dd></div> : null}
|
||||
{t.parent_task_id && t.kind !== "side" ? <div><dt>协调子任务</dt><dd>父任务 {shortId(t.parent_task_id)}{t.coordinator_part ? "(" + t.coordinator_part + ")" : ""}</dd></div> : null}
|
||||
{(t.attachments && t.attachments.length) ? <div><dt>附件</dt><dd>{t.attachments.join("、")}</dd></div> : null}
|
||||
{t.pr_url ? <div><dt>PR</dt><dd><a href={t.pr_url} target="_blank" rel="noopener noreferrer">PR #{t.pr_number}</a></dd></div> : null}
|
||||
</dl>
|
||||
@@ -151,6 +153,7 @@ export default function TaskDetailPanel() {
|
||||
</Card>
|
||||
</>) : tab === "collab" ? (<>
|
||||
<Card title="协调子任务"><CoordPanel /></Card>
|
||||
<Card title="旁聊"><SideThreadPanel /></Card>
|
||||
<Card title="子代理链路"><AgentsPanel /></Card>
|
||||
<Card title="给其他任务发消息"><PeerPanel /></Card>
|
||||
</>) : tab === "review" ? (<>
|
||||
|
||||
@@ -27,7 +27,17 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
|
||||
</div>
|
||||
<div className="list-section-head">
|
||||
<span>主要聊天</span>
|
||||
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="开旁聊"
|
||||
title={ctx.currentTask && ctx.currentTask.kind !== "side" && !ctx.currentTask.coordinator_parts ? "在选中主聊下开一个旁聊(不建 worktree)" : "先选中一个主聊任务,才能在它下面开旁聊"}
|
||||
disabled={!ctx.currentTask || ctx.currentTask.kind === "side" || !!ctx.currentTask.coordinator_parts}
|
||||
onClick={() => { if (ctx.currentTask) { ctx.setSideParentId(ctx.currentTask.id); onNew(); } }}
|
||||
>开旁聊</button>
|
||||
{" "}
|
||||
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bar">
|
||||
<select aria-label="状态筛选" value={ctx.statusFilter} onChange={(e) => ctx.setStatusFilter(e.target.value)}>
|
||||
@@ -37,6 +47,9 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
|
||||
<select aria-label="执行器筛选" value={ctx.executorFilter} onChange={(e) => ctx.setExecutorFilter(e.target.value)}>
|
||||
<option value="">执行器:全部</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
|
||||
</select>
|
||||
<select aria-label="类型筛选" value={ctx.kindFilter} onChange={(e) => ctx.setKindFilter(e.target.value)}>
|
||||
<option value="">类型:全部</option><option value="main">主聊</option><option value="side">旁聊</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="bar">
|
||||
<button onClick={ctx.refreshTasks}>刷新</button>
|
||||
@@ -62,7 +75,7 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
|
||||
<span className="issue-first" title={"任务首字:" + fc} aria-hidden="true">{fc}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="issue-title">
|
||||
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? "[协调子任务] " : ""}{String(t.prompt || shortId(t.id)).slice(0, 60)}
|
||||
{t.plan ? "[计划] " : ""}{t.forked_from ? "[分支] " : ""}{t.coordinator_parts ? "[协调] " : ""}{t.parent_task_id ? (t.kind === "side" ? "[旁聊] " : "[协调子任务] ") : ""}{String(t.prompt || shortId(t.id)).slice(0, 60)}
|
||||
</div>
|
||||
<div className="issue-meta">
|
||||
{shortId(t.id)} · <span className={"st-" + t.status}>{t.status}</span> · {t.executor || ""}{t.updated_at ? " · " + new Date(t.updated_at * 1000).toLocaleString() : ""}
|
||||
|
||||
@@ -12,18 +12,23 @@ export default function DetailActions() {
|
||||
if (!t) return null;
|
||||
const terminal = TRACE_TERMINAL.includes(t.status);
|
||||
const retryOk = ["failed", "interrupted", "merge-failed"].includes(t.status);
|
||||
// 旁聊(Muse 角色对齐 P2):无 worktree/分支,后端 /fork /merge
|
||||
// /pr 一律 409,这里只留重试/取消/查成本,不渲染那三处入口。
|
||||
const isSide = t.kind === "side";
|
||||
return (
|
||||
<>
|
||||
<Bar>
|
||||
<button disabled={!retryOk} title={retryOk ? "" : "仅 failed/interrupted/merge-failed 可重试"} onClick={() => ctx.doAction("retry")}>重试</button>
|
||||
<button className="danger" disabled={terminal} title={terminal ? "终态任务不可取消" : ""} onClick={() => ctx.doAction("cancel")}>取消</button>
|
||||
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
|
||||
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
|
||||
{terminal ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
|
||||
{isSide ? null : (<>
|
||||
<button disabled={t.status !== "completed"} title={t.status !== "completed" ? "仅 completed 可合并" : ""} onClick={() => ctx.doAction("merge")}>合并(completed → merged)</button>
|
||||
<button disabled={t.status !== "merged"} title={t.status !== "merged" ? "仅 merged 可提 PR" : ""} onClick={() => ctx.doAction("pr")}>提 PR(须 merged)</button>
|
||||
</>)}
|
||||
{terminal && !isSide ? <button onClick={() => ctx.setForkVisible(true)}>分支</button> : null}
|
||||
<button onClick={ctx.taskCost}>查成本</button>
|
||||
<span className="muted">{ctx.actionMsg} {ctx.actionReasons}</span>
|
||||
</Bar>
|
||||
{ctx.forkVisible ? (
|
||||
{ctx.forkVisible && !isSide ? (
|
||||
<div>
|
||||
<input type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style={{ width: "100%", boxSizing: "border-box", margin: "6px 0" }} value={forkMsg} onChange={(e) => setForkMsg(e.target.value)} />
|
||||
<Bar><button className="seal" onClick={async () => { await ctx.forkTask(forkMsg); setForkMsg(""); }}>创建分支任务</button><span className="muted">{ctx.forkMsg}</span></Bar>
|
||||
|
||||
@@ -19,6 +19,23 @@ export function CoordPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// 旁聊(Muse 角色对齐 P2):当前任务挂着的旁聊线程,与协调员
|
||||
// 子任务列表同款组件复用;点行切到该旁聊的详情。
|
||||
export function SideThreadPanel() {
|
||||
const ctx = useConsole();
|
||||
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示旁聊</div>;
|
||||
if (ctx.sideThreads.length === 0) return <div className="muted">(暂无旁聊;列表“开旁聊”可在此任务下新建,不建 worktree)</div>;
|
||||
return (
|
||||
<DataListRaw>
|
||||
{ctx.sideThreads.map((k) => (
|
||||
<li key={k.id} onClick={() => ctx.showDetail(k.id)} style={{ cursor: "pointer" }}>
|
||||
<span className="row-main">旁聊 | 状态 <span className={"st-" + k.status}>{k.status}</span> | {shortId(k.id)} | {String(k.prompt || "").slice(0, 60)}</span>
|
||||
</li>
|
||||
))}
|
||||
</DataListRaw>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPanel() {
|
||||
const ctx = useConsole();
|
||||
if (!ctx.currentTask) return <div className="muted">点任务行查看详情后,这里显示子代理链路</div>;
|
||||
|
||||
Reference in new issue
Block a user