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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, number>; 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<void>; 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<void>; submitFindings: (id: string) => void;
|
||||
traceEntries: TraceEntry[]; traceNote: string; traceDone: string; traceKind: string; setTraceKind: (v: string) => void; downloadTrace: () => void; traceMsg: string;
|
||||
sendChatMessage: (text: string) => Promise<void>; chatMsg: string;
|
||||
|
||||
@@ -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<string | null>(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,
|
||||
|
||||
@@ -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<Task[]>([]);
|
||||
// 旁聊(Muse 角色对齐 P2):当前任务挂着的旁聊线程(kind=side
|
||||
// 且 parent_task_id=本任务),与协调员子任务同款拉取方式。
|
||||
const [sideThreads, setSideThreads] = useState<Task[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [agentsNote, setAgentsNote] = useState("");
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
@@ -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<TaskCost>(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,
|
||||
|
||||
@@ -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<Record<string, number>>({});
|
||||
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<string, number> }>(
|
||||
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,
|
||||
|
||||
@@ -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