Files
codex-app-server/cloud/console-react/src/hooks/useTaskDetail.ts
T
host 7983567491
ci / test (push) Successful in 25m2s
feat: side-thread task kind (Muse role alignment phase 2)
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.
2026-11-07 20:51:23 +00:00

446 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
import { api, getKey } from "../api/client";
import { paths } from "../api/paths";
import { TRACE_TERMINAL } from "../api/types";
import type { Agent, Approval, ChatHistory, Followup, PeerMessage, Review, Task, TaskCost, ThreadInfo, TraceEntry } from "../api/types";
// Task detail state: the selected task, its trace SSE/one-shot stream,
// and every structured sub-list. Chat sending lives here too:
// terminal -> /continue, queued/running -> /followups (direct).
export function useTaskDetail(getRole: (pid?: string) => string | null, onTasksReload: () => void) {
const [currentTask, setCurrentTask] = useState<Task | null>(null);
const [detailBody, setDetailBody] = useState("");
const [actionMsg, setActionMsg] = useState("");
const [actionReasons, setActionReasons] = useState("");
const [costBox, setCostBox] = useState("");
// 成本明细:打开详情就自动加载(不再只靠“查成本”按钮出 JSON)。
// undefined = 加载中,null = 加载失败,对象 = 已加载。
const [costSummary, setCostSummary] = useState<TaskCost | null | undefined>(undefined);
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[]>([]);
const [reviewMsg, setReviewMsg] = useState("");
const [reviewError, setReviewError] = useState("");
const [traceEntries, setTraceEntries] = useState<TraceEntry[]>([]);
const [traceNote, setTraceNote] = useState("");
const [traceDone, setTraceDone] = useState("");
const [traceKind, setTraceKind] = useState("");
const [traceMsg, setTraceMsg] = useState("");
const [followups, setFollowups] = useState<Followup[]>([]);
const [approvals, setApprovals] = useState<Approval[]>([]);
const [approvalMsg, setApprovalMsg] = useState("");
const [peerMessages, setPeerMessages] = useState<PeerMessage[]>([]);
const [peerMsg, setPeerMsg] = useState("");
const [threadText, setThreadText] = useState("");
const [threadGateNote, setThreadGateNote] = useState("");
const [threadMsg, setThreadMsg] = useState("");
const [threadList, setThreadList] = useState<ThreadInfo[]>([]);
const [chatMsg, setChatMsg] = useState("");
const [chatHistory, setChatHistory] = useState<ChatHistory | null>(null);
const traceAbortRef = useRef<AbortController | null>(null);
const agentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTaskRef = useRef<Task | null>(null);
currentTaskRef.current = currentTask;
const stopTrace = useCallback(() => {
if (traceAbortRef.current) { try { traceAbortRef.current.abort(); } catch { /* noop */ } traceAbortRef.current = null; }
if (agentTimerRef.current) { clearTimeout(agentTimerRef.current); agentTimerRef.current = null; }
}, []);
const loadAgents = useCallback(async (taskId: string) => {
try {
const d = await api<{ agents?: Agent[]; note?: string }>(paths.taskAgents(taskId));
setAgents(d.agents || []);
setAgentsNote(d.note || "");
} catch (e: any) { setAgents([]); setAgentsNote("子代理链路加载失败:" + e.message); }
}, []);
const scheduleAgents = useCallback((taskId: string) => {
if (agentTimerRef.current) clearTimeout(agentTimerRef.current);
agentTimerRef.current = setTimeout(() => void loadAgents(taskId), 1000);
}, [loadAgents]);
const loadCoordChildren = useCallback(async (task: Task) => {
if (!task.coordinator_parts) { setCoordChildren([]); return; }
try {
const d = await api<{ tasks: Task[] }>(paths.tasksByParent(task.project_id || "", task.id));
setCoordChildren(d.tasks || []);
} 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));
setCostSummary(c);
} catch { setCostSummary(null); }
}, []);
const loadFollowups = useCallback(async (taskId: string) => {
try { const d = await api<{ followups: Followup[] }>(paths.taskFollowups(taskId)); setFollowups(d.followups || []); }
catch { setFollowups([]); }
}, []);
// True alternating history: server-merged prompt + persisted
// continues + followups + trace, in timestamp order. On failure
// (old server without /chat-history) keep null and ContinuePanel
// falls back to its previous prompt + trace + followups view.
const loadChatHistory = useCallback(async (taskId: string) => {
try { const d = await api<ChatHistory>(paths.taskChatHistory(taskId)); setChatHistory(d); }
catch { setChatHistory(null); }
}, []);
const loadReviews = useCallback(async (taskId?: string) => {
const id = taskId || currentTaskRef.current?.id;
if (!id) return;
try {
const d = await api<{ reviews: Review[] }>(paths.taskReviews(id));
setReviews(d.reviews || []);
setReviewError("");
} catch (e: any) { setReviewError("评审队列加载失败:" + e.message); }
}, []);
const loadApprovals = useCallback(async (taskId?: string) => {
const id = taskId || currentTaskRef.current?.id;
if (!id) return;
try {
const d = await api<{ approvals: Approval[] }>(paths.taskApprovals(id));
setApprovals(d.approvals || []);
} catch (e: any) { setApprovalMsg("审批队列加载失败:" + e.message); }
}, []);
const loadPeerMessages = useCallback(async (taskId: string) => {
try { const d = await api<{ messages: PeerMessage[] }>(paths.taskMessages(taskId)); setPeerMessages(d.messages || []); }
catch { setPeerMessages([]); }
}, []);
const loadThread = useCallback(async (taskId: string) => {
try {
const d = await api<{ thread?: ThreadInfo } & ThreadInfo>(paths.taskThread(taskId));
const th = d.thread || d;
let turns = th.turns;
if (turns === undefined || turns === null) turns = th.turn_count;
if (turns === undefined || turns === null) turns = th.turns_count;
setThreadText("id " + (th.id || taskId) + " | 名字 " + (th.name || th.thread_name || "") + " | 归档 " + (th.archived === undefined || th.archived === null ? "" : (th.archived ? "是" : "否")) + " | cwd " + (th.cwd || "") + (turns !== undefined && turns !== null ? " | turn 数 " + turns : ""));
} catch (e: any) { setThreadText("线程加载失败:" + e.message); }
}, []);
const loadThreads = useCallback(async (taskId: string) => {
try { const d = await api<{ threads: ThreadInfo[] }>(paths.taskThreads(taskId)); setThreadList(d.threads || []); }
catch { setThreadList([]); }
}, []);
const setThreadGate = useCallback((status: string) => {
const ok = ["completed", "failed", "interrupted"].includes(status);
setThreadGateNote(ok ? "终态任务,线程操作可用" : "仅终态任务可操作线程,当前状态 " + status + ",按钮已置灰");
}, []);
const startTrace = useCallback(async (taskId: string, status: string) => {
stopTrace();
setTraceEntries([]);
setTraceNote("");
setTraceDone("");
if (TRACE_TERMINAL.includes(status)) {
try {
const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(taskId));
setTraceEntries(d.entries || []);
setTraceNote(d.note || "");
setTraceDone(d.status || "");
} catch (e: any) { setTraceNote("链路加载失败:" + e.message); }
return;
}
const ctrl = new AbortController();
traceAbortRef.current = ctrl;
let entries: TraceEntry[] = [];
const index: Record<string, number> = {};
let note = "";
let done = false;
let doneStatus = "";
try {
const headers: Record<string, string> = {};
const key = getKey();
if (key) headers["X-API-Key"] = key;
const r = await fetch(paths.taskTraceStream(taskId), { headers, signal: ctrl.signal });
if (!r.ok) {
let msg = "HTTP " + r.status;
try { const dj = await r.json(); if (dj && dj.error) msg = dj.error; } catch { /* noop */ }
if (r.status === 401) msg += "(key 未填或无效)";
throw new Error(msg);
}
const reader = r.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() || "";
for (const part of parts) {
const line = part.split("\n").filter((l) => l.indexOf("data: ") === 0).map((l) => l.slice(6)).join("\n");
if (!line) continue;
try {
const m = JSON.parse(line);
if (m.type === "snapshot") {
entries = m.entries || [];
Object.keys(index).forEach((k) => delete index[k]);
entries.forEach((e: TraceEntry, i: number) => { if (e.id) index[e.id] = i; });
note = m.note || "";
setTraceEntries([...entries]);
setTraceNote(note);
scheduleAgents(taskId);
} else if (m.type === "entry") {
const e = m.entry as TraceEntry;
if (e.id && e.id in index) entries[index[e.id]] = e;
else { if (e.id) index[e.id] = entries.length; entries.push(e); }
setTraceEntries([...entries]);
scheduleAgents(taskId);
} else if (m.type === "done") {
done = true;
doneStatus = m.status;
setTraceDone(doneStatus);
}
} catch { /* ignore malformed SSE frame */ }
}
if (done) break;
}
} catch (e: any) {
if (e && e.name === "AbortError") return;
setTraceNote("链路流中断:" + e.message);
return;
}
if (done) { onTasksReload(); }
}, [stopTrace, scheduleAgents, onTasksReload]);
const showDetail = useCallback(async (taskId: string) => {
stopTrace();
try {
const t = await api<Task>(paths.task(taskId));
setCurrentTask(t);
let body = "";
if (t.result_text) body += "=== result ===\n" + t.result_text.slice(0, 8000) + "\n";
if (t.error_text) body += "=== error ===\n" + t.error_text.slice(0, 8000) + "\n";
if (!body) body = "(暂无 result/error 输出)";
setDetailBody(body);
setCostBox("");
setCostSummary(undefined);
void loadCost(taskId);
setForkVisible(false);
setForkMsg("");
setPeerMsg("");
setThreadMsg("");
setThreadGate(t.status);
// Button gating by role: maintainer/owner for retry/merge/pr,
// contributor+ for cancel; the right panel disables with reasons.
const role = getRole(t.project_id);
const isMaintainer = role === "maintainer" || role === "owner";
const canCreate = role === "contributor" || isMaintainer;
const terminal = TRACE_TERMINAL.includes(t.status);
const reasons: string[] = [];
if (!((["failed", "interrupted", "merge-failed"].includes(t.status)) && isMaintainer)) reasons.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试" + (isMaintainer ? "" : ",且仅 maintainer 可重试"));
if (!(!terminal && canCreate)) reasons.push("取消:不可用——" + (terminal ? "终态任务不可取消" : "仅项目成员(contributor 及以上)可取消"));
if (!(t.status === "completed" && isMaintainer)) reasons.push("合并:不可用——仅 completed 可合并" + (isMaintainer ? "" : ",且仅 maintainer 可合并"));
if (!(t.status === "merged" && isMaintainer)) reasons.push("提 PR:不可用——仅 merged 可提 PR" + (isMaintainer ? "" : ",且仅 maintainer 可提 PR"));
setActionReasons(reasons.join(";"));
setActionMsg("");
void startTrace(taskId, t.status);
void loadAgents(taskId);
void loadCoordChildren(t);
void loadSideThreads(t);
void loadChatHistory(taskId);
void loadFollowups(taskId);
void loadPeerMessages(taskId);
void loadThread(taskId);
void loadThreads(taskId);
void loadReviews(taskId);
void loadApprovals(taskId);
} catch (e: any) {
setCurrentTask(null);
setDetailBody("加载失败:" + e.message);
}
}, [stopTrace, startTrace, loadAgents, loadCoordChildren, loadSideThreads, loadChatHistory, loadFollowups, loadPeerMessages, loadThread, loadThreads, loadReviews, loadApprovals, loadCost, getRole]);
const refreshDetailLists = useCallback(async () => {
const t = currentTaskRef.current;
if (!t) return;
try {
const fresh = await api<Task>(paths.task(t.id));
setCurrentTask(fresh);
let body = "";
if (fresh.result_text) body += "=== result ===\n" + fresh.result_text.slice(0, 8000) + "\n";
if (fresh.error_text) body += "=== error ===\n" + fresh.error_text.slice(0, 8000) + "\n";
setDetailBody(body || "(暂无 result/error 输出)");
} catch { /* light header refresh must not clear the detail */ }
void loadFollowups(t.id);
void loadChatHistory(t.id);
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, loadSideThreads, loadAgents, loadPeerMessages, loadThread, loadThreads, loadCost]);
const doAction = useCallback(async (kind: string) => {
const t = currentTaskRef.current;
if (!t) return;
const confirmNeeded: Record<string, string> = {
cancel: "确定取消该任务吗?", merge: "确定把 completed 任务标为 merged 吗?", pr: "确定为该任务提 PR 吗?(会在仓库真实建 PR)",
};
if (confirmNeeded[kind] && !window.confirm(confirmNeeded[kind])) return;
try {
const r = await api<unknown>(paths.taskAction(t.id, kind), "POST", {});
setActionMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300));
onTasksReload();
await showDetail(t.id);
} catch (e: any) { setActionMsg(kind + " 失败:" + e.message); }
}, [showDetail, onTasksReload]);
const taskCost = useCallback(async () => {
const t = currentTaskRef.current;
if (!t) return;
try {
const c = await api<unknown>(paths.taskCost(t.id));
setCostBox(JSON.stringify(c, null, 2).slice(0, 4000));
} catch (e: any) { setCostBox("查成本失败:" + e.message); }
}, []);
const forkTask = useCallback(async (msg: string) => {
const t = currentTaskRef.current;
if (!t) return;
if (!msg.trim()) { setForkMsg("分支消息必填"); return; }
if (!TRACE_TERMINAL.includes(t.status)) { setForkMsg("仅终态任务可分支"); return; }
try {
const nt = await api<Task>(paths.taskFork(t.id), "POST", { message: msg.trim() });
setForkMsg("分支任务已建:" + nt.id.slice(0, 8));
onTasksReload();
} catch (e: any) { setForkMsg("分支失败:" + e.message); }
}, [onTasksReload]);
const postReview = useCallback(async (body: { target_type: string; branch?: string; sha?: string; instructions?: string }) => {
const t = currentTaskRef.current;
if (!t) return;
try {
await api(paths.taskReviews(t.id), "POST", body);
setReviewMsg("评审已登记(requested)");
await loadReviews(t.id);
} catch (e: any) { setReviewMsg("登记评审需求失败:" + e.message); }
}, [loadReviews]);
const submitFindings = useCallback((reviewId: string) => {
const t = currentTaskRef.current;
if (!t) return;
const text = window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");
if (text === null) return;
void api(paths.taskReviewFindings(t.id, reviewId), "POST", { findings: text })
.then(() => void loadReviews(t.id))
.catch((e: any) => setReviewMsg("写 findings 失败:" + e.message));
}, [loadReviews]);
const decideApproval = useCallback((approvalId: string, decision: string) => {
const t = currentTaskRef.current;
if (!t) return;
const verb = decision === "approved" ? "approve" : "deny";
void api(paths.taskApprovalDecision(t.id, approvalId, verb), "POST", {})
.then(() => { setApprovalMsg("已记录" + (decision === "approved" ? "允许" : "拒绝") + "(当前版本只留回执,不拦截实时行为)"); void loadApprovals(t.id); })
.catch((e: any) => setApprovalMsg("决定失败:" + e.message));
}, [loadApprovals]);
const postPeer = useCallback(async (mode: string, target: string, text: string) => {
const t = currentTaskRef.current;
if (!t) return;
if (!target.trim()) { setPeerMsg("目标任务 id 不能为空"); return; }
if (!text.trim()) { setPeerMsg("消息正文不能为空"); return; }
try {
const d = await api<{ delivery?: string }>(paths.taskMessagesTo(target.trim()), "POST", { from_task_id: t.id, body: text.trim(), mode });
setPeerMsg("已发送(" + d.delivery + ")");
await loadPeerMessages(t.id);
} catch (e: any) { setPeerMsg("发送失败:" + e.message); }
}, [loadPeerMessages]);
const threadAction = useCallback((kind: string, name: string) => {
const t = currentTaskRef.current;
if (!t) return;
if (!["completed", "failed", "interrupted"].includes(t.status)) {
setThreadMsg("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");
return;
}
const body: { name?: string } = {};
if (kind === "rename") {
if (!name.trim()) { setThreadMsg("新线程名不能为空"); return; }
body.name = name.trim();
}
void api(paths.taskThreadAction(t.id, kind), "POST", body)
.then((r: unknown) => { setThreadMsg(kind + " 已提交:" + JSON.stringify(r).slice(0, 300)); void loadThread(t.id); void loadThreads(t.id); })
.catch((e: any) => setThreadMsg(kind + " 失败:" + e.message));
}, [loadThread, loadThreads]);
const sendChatMessage = useCallback(async (text: string) => {
const t = currentTaskRef.current;
if (!t) return;
const v = text.trim();
if (!v) { setChatMsg("消息不能为空"); return; }
try {
if (TRACE_TERMINAL.includes(t.status) && t.status !== "merged") {
// Message-only continue: all optional fields blank, so the
// server keeps the task's original model/effort/approval.
await api(paths.taskContinue(t.id), "POST", { message: v });
setChatMsg("已重新排队,同一会话继续");
onTasksReload();
await showDetail(t.id);
} else if (["queued", "running"].includes(t.status)) {
await api(paths.taskFollowups(t.id), "POST", { body: v, mode: "direct" });
setChatMsg("已直接发送");
await loadFollowups(t.id);
await loadChatHistory(t.id);
} else {
setChatMsg("merged/merge-failed 任务不可继续,也不可发后续消息");
}
} catch (e: any) { setChatMsg("发送失败:" + e.message); }
}, [showDetail, onTasksReload, loadFollowups, loadChatHistory]);
const downloadTrace = useCallback(async () => {
const t = currentTaskRef.current;
if (!t) { setTraceMsg("先选一个任务"); return; }
try {
const d = await api<{ entries: TraceEntry[]; note?: string; status?: string }>(paths.taskTrace(t.id));
const blob = new Blob([JSON.stringify({ task_id: t.id, status: d.status, note: d.note || "", count: (d.entries || []).length, entries: d.entries || [] }, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "trace-" + t.id.slice(0, 8) + ".json";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
setTraceMsg("已下载完整链路 " + (d.entries || []).length + " 条");
} catch (e: any) { setTraceMsg("下载失败:" + e.message); }
}, []);
useEffect(() => () => stopTrace(), [stopTrace]);
return {
currentTask, detailBody, actionMsg, actionReasons, costBox, costSummary, forkVisible, setForkVisible, forkMsg,
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,
decideApproval, postPeer, threadAction, sendChatMessage, downloadTrace,
refreshReviews: () => void loadReviews(), refreshApprovals: () => void loadApprovals(),
};
}